Compare commits

...

6 commits

6 changed files with 251 additions and 6 deletions

View file

@ -23,7 +23,11 @@ struct Args {
#[derive(clap::Subcommand, Debug)] #[derive(clap::Subcommand, Debug)]
enum Commands { enum Commands {
/// List issues. /// 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. /// Create a new issue.
New { description: Option<String> }, New { description: Option<String> },
@ -39,15 +43,24 @@ enum Commands {
issue_id: String, issue_id: String,
new_state: Option<State>, new_state: Option<State>,
}, },
/// Create a new comment on an issue.
Comment {
issue_id: String,
description: Option<String>,
},
} }
fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> {
match &args.command { match &args.command {
Commands::List => { Commands::List { filter } => {
let issues = let issues =
entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; 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() { 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);
}
} }
} }
@ -90,6 +103,11 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<(
} }
println!(""); println!("");
println!("{}", issue.description); println!("{}", issue.description);
for (uuid, comment) in issue.comments.iter() {
println!("");
println!("comment: {}", uuid);
println!("{}", comment.description);
}
} }
None => { None => {
return Err(anyhow::anyhow!("issue {} not found", issue_id)); return Err(anyhow::anyhow!("issue {} not found", issue_id));
@ -123,6 +141,26 @@ 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));
};
let mut comment = issue.new_comment()?;
match description {
Some(description) => {
comment.set_description(description)?;
}
None => {
comment.edit_description()?;
}
}
}
} }
Ok(()) Ok(())

104
src/comment.rs Normal file
View file

@ -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<Self, CommentError> {
let mut description: Option<String> = 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);
}
}

View file

@ -5,7 +5,7 @@ use std::str::FromStr;
#[cfg(feature = "log")] #[cfg(feature = "log")]
use log::debug; 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. /// These are the states an issue can be in.
pub enum State { pub enum State {
New, New,
@ -23,6 +23,7 @@ pub struct Issue {
pub description: String, pub description: String,
pub state: State, pub state: State,
pub dependencies: Option<Vec<IssueHandle>>, pub dependencies: Option<Vec<IssueHandle>>,
pub comments: std::collections::HashMap<String, crate::comment::Comment>,
/// This is the directory that the issue lives in. Only used /// This is the directory that the issue lives in. Only used
/// internally by the entomologist library. /// internally by the entomologist library.
@ -33,8 +34,12 @@ pub struct Issue {
pub enum IssueError { pub enum IssueError {
#[error(transparent)] #[error(transparent)]
StdIoError(#[from] std::io::Error), StdIoError(#[from] std::io::Error),
#[error(transparent)]
CommentError(#[from] crate::comment::CommentError),
#[error("Failed to parse issue")] #[error("Failed to parse issue")]
IssueParseError, IssueParseError,
#[error("Failed to parse state")]
StateParseError,
#[error("Failed to run git")] #[error("Failed to run git")]
GitError(#[from] crate::git::GitError), GitError(#[from] crate::git::GitError),
#[error("Failed to run editor")] #[error("Failed to run editor")]
@ -58,7 +63,7 @@ impl FromStr for State {
} else if s == "wontdo" { } else if s == "wontdo" {
Ok(State::WontDo) Ok(State::WontDo)
} else { } else {
Err(IssueError::IssueParseError) Err(IssueError::StateParseError)
} }
} }
} }
@ -72,7 +77,6 @@ impl fmt::Display for State {
State::InProgress => "inprogress", State::InProgress => "inprogress",
State::Done => "done", State::Done => "done",
State::WontDo => "wontdo", State::WontDo => "wontdo",
}; };
write!(f, "{fmt_str}") write!(f, "{fmt_str}")
} }
@ -83,6 +87,7 @@ impl Issue {
let mut description: Option<String> = None; let mut description: Option<String> = None;
let mut state = State::New; // default state, if not specified in the issue let mut state = State::New; // default state, if not specified in the issue
let mut dependencies: Option<Vec<String>> = None; let mut dependencies: Option<Vec<String>> = None;
let mut comments = std::collections::HashMap::<String, crate::comment::Comment>::new();
for direntry in dir.read_dir()? { for direntry in dir.read_dir()? {
if let Ok(direntry) = direntry { if let Ok(direntry) = direntry {
@ -101,6 +106,8 @@ impl Issue {
if deps.len() > 0 { if deps.len() > 0 {
dependencies = Some(deps); dependencies = Some(deps);
} }
} else if file_name == "comments" && direntry.metadata()?.is_dir() {
Self::read_comments(&mut comments, &direntry.path())?;
} else { } else {
#[cfg(feature = "log")] #[cfg(feature = "log")]
debug!("ignoring unknown file in issue directory: {:?}", file_name); debug!("ignoring unknown file in issue directory: {:?}", file_name);
@ -116,10 +123,43 @@ impl Issue {
description: description.unwrap(), description: description.unwrap(),
state: state, state: state,
dependencies, dependencies,
comments,
dir: std::path::PathBuf::from(dir), dir: std::path::PathBuf::from(dir),
}) })
} }
fn read_comments(
comments: &mut std::collections::HashMap<String, crate::comment::Comment>,
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<crate::comment::Comment, IssueError> {
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<Self, IssueError> { pub fn new(dir: &std::path::Path) -> Result<Self, IssueError> {
let mut issue_dir = std::path::PathBuf::from(dir); let mut issue_dir = std::path::PathBuf::from(dir);
let rnd: u128 = rand::random(); let rnd: u128 = rand::random();
@ -129,6 +169,7 @@ impl Issue {
description: String::from(""), // FIXME: kind of bogus to use the empty string as None description: String::from(""), // FIXME: kind of bogus to use the empty string as None
state: State::New, state: State::New,
dependencies: None, dependencies: None,
comments: std::collections::HashMap::<String, crate::comment::Comment>::new(),
dir: issue_dir, dir: issue_dir,
}) })
} }
@ -204,6 +245,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"), 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, state: State::New,
dependencies: None, dependencies: None,
comments: std::collections::HashMap::<String, crate::comment::Comment>::new(),
dir: std::path::PathBuf::from(issue_dir), dir: std::path::PathBuf::from(issue_dir),
}; };
assert_eq!(issue, expected); assert_eq!(issue, expected);
@ -217,6 +259,7 @@ mod tests {
description: String::from("minimal"), description: String::from("minimal"),
state: State::InProgress, state: State::InProgress,
dependencies: None, dependencies: None,
comments: std::collections::HashMap::<String, crate::comment::Comment>::new(),
dir: std::path::PathBuf::from(issue_dir), dir: std::path::PathBuf::from(issue_dir),
}; };
assert_eq!(issue, expected); assert_eq!(issue, expected);

View file

@ -99,6 +99,7 @@ mod tests {
description: String::from("minimal"), description: String::from("minimal"),
state: crate::issue::State::InProgress, state: crate::issue::State::InProgress,
dependencies: None, dependencies: None,
comments: std::collections::HashMap::<String, crate::comment::Comment>::new(),
dir, 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"), 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, state: crate::issue::State::New,
dependencies: None, dependencies: None,
comments: std::collections::HashMap::<String, crate::comment::Comment>::new(),
dir, dir,
} }
); );
@ -134,6 +136,7 @@ mod tests {
description: String::from("oh yeah we got titles"), description: String::from("oh yeah we got titles"),
state: crate::issue::State::Done, state: crate::issue::State::Done,
dependencies: None, dependencies: None,
comments: std::collections::HashMap::<String, crate::comment::Comment>::new(),
dir, dir,
}, },
); );
@ -141,12 +144,26 @@ mod tests {
let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"); let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561");
let mut dir = std::path::PathBuf::from(issues_dir); let mut dir = std::path::PathBuf::from(issues_dir);
dir.push(&uuid); 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::<String, crate::comment::Comment>::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( expected.add_issue(
uuid, uuid,
crate::issue::Issue { 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"), 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, state: crate::issue::State::WontDo,
dependencies: None, dependencies: None,
comments: expected_comments,
dir, dir,
}, },
); );
@ -169,6 +186,7 @@ mod tests {
description: String::from("oh yeah we got titles\n"), description: String::from("oh yeah we got titles\n"),
state: crate::issue::State::Done, state: crate::issue::State::Done,
dependencies: None, dependencies: None,
comments: std::collections::HashMap::<String, crate::comment::Comment>::new(),
dir, 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"), 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, state: crate::issue::State::WontDo,
dependencies: None, dependencies: None,
comments: std::collections::HashMap::<String, crate::comment::Comment>::new(),
dir, dir,
}, },
); );
@ -198,6 +217,7 @@ mod tests {
crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"),
crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"),
]), ]),
comments: std::collections::HashMap::<String, crate::comment::Comment>::new(),
dir, dir,
}, },
); );

View file

@ -1,3 +1,40 @@
use std::str::FromStr;
pub mod comment;
pub mod git; pub mod git;
pub mod issue; pub mod issue;
pub mod issues; 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<crate::issue::State>,
}
// Parses a filter description matching "state=STATE[,STATE*]"
pub fn parse_filter(filter_str: &str) -> Result<Filter, ParseFilterError> {
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::<crate::issue::State>::new();
for s in tokens[1].split(",") {
include_states.insert(crate::issue::State::from_str(s)?);
}
Ok(Filter { include_states })
}

View file

@ -0,0 +1,3 @@
This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561
It has multiple lines