diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 96ef6c0..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/target -Cargo.lock diff --git a/1f85dfac686d5ea2417b2b07f7e1ff01/description b/1f85dfac686d5ea2417b2b07f7e1ff01/description new file mode 100644 index 0000000..8118186 --- /dev/null +++ b/1f85dfac686d5ea2417b2b07f7e1ff01/description @@ -0,0 +1,4 @@ +# implement `ent attach ${ISSUE} ${FILE}` + +- each issue has its own independent namespace for attached files +- issue description & comments can reference attached files via standard md links diff --git a/75cefad80aacbf23fc7b9c24a75aa236/description b/75cefad80aacbf23fc7b9c24a75aa236/description new file mode 100644 index 0000000..212fa34 --- /dev/null +++ b/75cefad80aacbf23fc7b9c24a75aa236/description @@ -0,0 +1,6 @@ +# implement `ent comment ${ISSUE} [-m ${MESSAGE}]` + +- each issue dir has a `comments` subdir +- each comment is identified by a sha1-style uid +- each comment is a file or directory under the `${ISSUE}/comments` +- comments are ordered by ctime? diff --git a/7da3bd5b72de0a05936b094db5d24304/description b/7da3bd5b72de0a05936b094db5d24304/description new file mode 100644 index 0000000..f23a78e --- /dev/null +++ b/7da3bd5b72de0a05936b094db5d24304/description @@ -0,0 +1 @@ +implement `ent edit ${COMMENT}` \ No newline at end of file diff --git a/8c73c9fd5bc4f551ee5069035ae6e866/description b/8c73c9fd5bc4f551ee5069035ae6e866/description new file mode 100644 index 0000000..9876137 --- /dev/null +++ b/8c73c9fd5bc4f551ee5069035ae6e866/description @@ -0,0 +1 @@ +migrate the Todo list into entomologist \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 4d2d2c5..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "entomologist" -version = "0.1.0" -edition = "2024" - -[features] -default = [] -log = ["dep:log", "dep:simple_logger"] - -[dev-dependencies] -pretty_assertions = "1.4.1" - -[dependencies] -anyhow = "1.0.95" -chrono = "0.4.41" -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"] } -simple_logger = { version = "5.0.0", optional = true } -tempfile = "3.20.0" -thiserror = "2.0.11" -toml = "0.8.19" diff --git a/README.md b/README.md index 71a2f8e..2bd9d23 100644 --- a/README.md +++ b/README.md @@ -1,81 +1 @@ -Entomologist is a distributed, collaborative, offline-first issue tracker, -backed by git. - - -# Quick start - -Entomologist provides a single executable called `ent` which performs -all interaction with the issues database. `ent --help` provides terse -usage info. - -No initialization is needed, just start using `ent` inside your git repo: - -``` -$ git clone git@server:my-repo.git -$ cd my-repo -$ ent list -# no issues shown, unless my-repo contained some already -``` - -Create an issue: -``` -$ ent new -# Starts your $EDITOR. Type in the issue description, "git-commit -# style" with a title line, optionally followed by an empty line and -# free form text. -``` - -List issues with `ent list`. Optionally takes a filter argument that -controls which issues are shown, see `ent list --help` for details. -For example, to show only new and backlog issues assigned to me or -unassigned, run `ent list state=new,backlog:assignee=$(whoami),`. - -Show all details of an issue with `ent show`. - -Modify the state of an issue using `ent state`. Supported states are New, -Backlog, InProgress, Done, and WontDo. - -Assign an issue to a person using `ent assign`. The person is just -a free-form text field for now. Make it a name, or an email address, -or whatever you want. - -Add a comment on an issue with `ent comment`. - -Edit an issue or a comment with `ent edit`. - -Add or remove tags on an issue using `ent tag`. - - -# Synchronization - -Synchronize your local issue database with the server using `ent sync`. -This will: - -1. Fetch the remote issue database branch into your local repo. - -2. Show the list of local changes not yet on the remote. - -3. Show the list of remote changes not yet incorporated into the local - branch. - -4. Merge the branches. - -5. Push the result back to the remote. - -Step 4 might fail if (for example) both sides edited the same issue in -a way that git can't merge automatically. In this case, check out the -`entomologist-data` branch, merge by hand and resolve the conflicts, -and run `ent sync` again. - - -# Git storage - -Issues are stored in a normal orphan branch in a git repo, next to but -independent of whatever else is stored in the repo. The default branch -name is `entomologist-data`. - -Anyone who has a clone of the repo has the complete issue database. - -Anyone who has write-access to the repo can modify the issue database. -The issue database branch can be modified by pull request, same as any -other branch. +This branch is used by entomologist to track issues. \ No newline at end of file diff --git a/b738f2842db428df1b4aad0192a7f36c/description b/b738f2842db428df1b4aad0192a7f36c/description new file mode 100644 index 0000000..097bfa4 --- /dev/null +++ b/b738f2842db428df1b4aad0192a7f36c/description @@ -0,0 +1 @@ +write a manpage \ No newline at end of file diff --git a/da435e5e298b28dc223f9dcfe62a914/description b/da435e5e298b28dc223f9dcfe62a914/description new file mode 100644 index 0000000..a1ba09a --- /dev/null +++ b/da435e5e298b28dc223f9dcfe62a914/description @@ -0,0 +1 @@ +add user control over state transitions \ No newline at end of file diff --git a/install.sh b/install.sh deleted file mode 100755 index ba8faf0..0000000 --- a/install.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) -BINFILE="${SCRIPT_DIR}/target/release/ent" -INSTALL_DIR="/usr/local/bin" - -cargo build --release -echo "copying ent to ${INSTALL_DIR}" -sudo cp $BINFILE $INSTALL_DIR -echo "ent installed to ${INSTALL_DIR}" diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs deleted file mode 100644 index e071233..0000000 --- a/src/bin/ent/main.rs +++ /dev/null @@ -1,597 +0,0 @@ -use clap::Parser; - -use entomologist::issue::State; -#[cfg(feature = "log")] -use simple_logger; - -#[derive(Debug, clap::Parser)] -#[command(version, about, long_about = None)] -struct Args { - /// Directory containing issues. - #[arg(short = 'd', long)] - issues_dir: Option, - - /// Branch containing issues. - #[arg(short = 'b', long)] - issues_branch: Option, - - /// Type of behavior/output. - #[command(subcommand)] - command: Commands, -} - -#[derive(clap::Subcommand, Debug)] -enum Commands { - /// List issues. - List { - /// Filter strings, describes issues to include in the list. - /// Each filter string is of the form "name=condition". - /// The supported names and their matching conditions are: - /// - /// "state": Comma-separated list of states to list. - /// Example: "state=new,backlog". Defaults to - /// "new,backlog,blocked,inprogress". - /// - /// "assignee": Comma-separated list of assignees to include in - /// the list. The empty string includes issues with no assignee. - /// Example: "assignee=seb," lists issues assigned to "seb" and - /// issues without an assignee. Defaults to include all issues. - /// - /// "tag": Comma-separated list of tags to include, or exclude - /// if prefixed with "-". Example: "tag=bug,-docs" shows issues - /// that are tagged "bug" and not tagged "docs". Defaults to - /// including all tags and excluding none. - /// - /// "done-time": Time range of issue completion, in the form - /// "[START]..[END]". Includes issues that were marked Done - /// between START and END. START and END are both in RFC 3339 - /// format, e.g. "YYYY-MM-DDTHH:MM:SS[+-]HH:MM". If START - /// is omitted, defaults to the beginning of time. If END is - /// omitted, defaults to the end of time. - filter: Vec, - }, - - /// Create a new issue. - New { description: Option }, - - /// Edit the description of an Issue or a Comment. - Edit { uuid: String }, - - /// Show the full description of an issue. - Show { issue_id: String }, - - /// Modify the state of an issue - State { - issue_id: String, - new_state: Option, - }, - - /// Create a new comment on an issue. - Comment { - issue_id: String, - description: Option, - }, - - /// Sync entomologist data with remote. This fetches from the remote, - /// merges the remote entomologist data branch with the local one, - /// and pushes the result back to the remote. - Sync { - /// Name of the git remote to sync with. - #[arg(default_value_t = String::from("origin"))] - remote: String, - }, - - /// Get or set the Assignee field of an Issue. - Assign { - 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, - }, - - /// Get or set the `done_time` of the Issue. - DoneTime { - issue_id: String, - done_time: Option, - }, - - /// get or add a dependency to the issue - Depend { - issue_id: String, - dependency_id: Option, - }, -} - -fn handle_command( - args: &Args, - issues_database_source: &entomologist::database::IssuesDatabaseSource, -) -> anyhow::Result<()> { - match &args.command { - Commands::List { filter } => { - let issues = entomologist::database::read_issues_database(issues_database_source)?; - let filter = { - let mut f = entomologist::Filter::new(); - for filter_str in filter { - f.parse(filter_str)?; - } - f - }; - - let mut uuids_by_state = std::collections::HashMap::< - entomologist::issue::State, - Vec<&entomologist::issue::IssueHandle>, - >::new(); - for (uuid, issue) in issues.issues.iter() { - if !filter.include_states.contains(&issue.state) { - continue; - } - if filter.include_assignees.len() > 0 { - let assignee = match &issue.assignee { - Some(assignee) => assignee, - None => "", - }; - if !filter.include_assignees.contains(assignee) { - continue; - } - } - - 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; - } - } - - if let Some(issue_done_time) = issue.done_time { - if let Some(start_done_time) = filter.start_done_time { - if start_done_time > issue_done_time { - continue; - } - } - if let Some(end_done_time) = filter.end_done_time { - if end_done_time < issue_done_time { - continue; - } - } - } - - // This issue passed all the filters, include it in list. - uuids_by_state - .entry(issue.state.clone()) - .or_default() - .push(uuid); - } - - use entomologist::issue::State; - for state in [ - State::InProgress, - State::Blocked, - State::Backlog, - State::New, - State::Done, - State::WontDo, - ] { - let these_uuids = uuids_by_state.entry(state.clone()).or_default(); - if these_uuids.len() == 0 { - continue; - } - these_uuids.sort_by(|a_id, b_id| { - let a = issues.issues.get(*a_id).unwrap(); - let b = issues.issues.get(*b_id).unwrap(); - a.creation_time.cmp(&b.creation_time) - }); - println!("{:?}:", state); - for uuid in these_uuids { - let issue = issues.issues.get(*uuid).unwrap(); - let comments = match issue.comments.len() { - 0 => String::from(" "), - n => format!("🗨️ {}", n), - }; - let assignee = match &issue.assignee { - Some(assignee) => format!(" (👉 {})", assignee), - None => String::from(""), - }; - 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!(""); - } - } - - Commands::New { description } => { - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::IssuesDatabaseAccess::ReadWrite, - )?; - match entomologist::issue::Issue::new(&issues_database.dir, description) { - Err(entomologist::issue::IssueError::EmptyDescription) => { - println!("no new issue created"); - return Ok(()); - } - Err(e) => { - return Err(e.into()); - } - Ok(issue) => { - println!("created new issue '{}'", issue.title()); - println!("ID: {}", issue.id); - return Ok(()); - } - } - } - - Commands::Edit { uuid } => { - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::IssuesDatabaseAccess::ReadWrite, - )?; - let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; - if let Some(issue) = issues.get_mut_issue(uuid) { - match issue.edit_description() { - Err(entomologist::issue::IssueError::EmptyDescription) => { - println!("aborted issue edit"); - return Ok(()); - } - Err(e) => return Err(e.into()), - Ok(()) => return Ok(()), - } - } - // No issue by that ID, check all the comments. - for (_, issue) in issues.issues.iter_mut() { - for comment in issue.comments.iter_mut() { - if comment.uuid == *uuid { - match comment.edit_description() { - Err(entomologist::comment::CommentError::EmptyDescription) => { - println!("aborted comment edit"); - return Ok(()); - } - Err(e) => return Err(e.into()), - Ok(()) => return Ok(()), - } - } - } - } - return Err(anyhow::anyhow!( - "no issue or comment with uuid {} found", - uuid - )); - } - - Commands::Show { issue_id } => { - let issues = entomologist::database::read_issues_database(issues_database_source)?; - let Some(issue) = issues.get_issue(issue_id) else { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); - }; - println!("issue {}", issue_id); - println!("author: {}", issue.author); - if issue.tags.len() > 0 { - print!("tags: "); - let mut separator = ""; - for tag in &issue.tags { - print!("{}{}", separator, tag); - separator = ", "; - } - println!(""); - } - println!("creation_time: {}", issue.creation_time); - if let Some(done_time) = &issue.done_time { - println!("done_time: {}", done_time); - } - println!("state: {:?}", issue.state); - if let Some(dependencies) = &issue.dependencies { - println!("dependencies: {:?}", dependencies); - } - if let Some(assignee) = &issue.assignee { - println!("assignee: {}", assignee); - } - println!(""); - println!("{}", issue.description); - for comment in &issue.comments { - println!(""); - println!("comment: {}", comment.uuid); - println!("author: {}", comment.author); - println!("creation_time: {}", comment.creation_time); - println!(""); - println!("{}", comment.description); - } - } - - Commands::State { - issue_id, - new_state, - } => match new_state { - Some(new_state) => { - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::IssuesDatabaseAccess::ReadWrite, - )?; - let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; - match issues.issues.get_mut(issue_id) { - Some(issue) => { - let current_state = issue.state.clone(); - issue.set_state(new_state.clone())?; - println!("issue: {}", issue_id); - println!("state: {} -> {}", current_state, new_state); - } - None => { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); - } - } - } - None => { - let issues = entomologist::database::read_issues_database(issues_database_source)?; - match issues.issues.get(issue_id) { - Some(issue) => { - println!("issue: {}", issue_id); - println!("state: {}", issue.state); - } - None => { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); - } - } - } - }, - - Commands::Comment { - issue_id, - description, - } => { - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::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)); - }; - match issue.add_comment(description) { - Err(entomologist::issue::IssueError::CommentError( - entomologist::comment::CommentError::EmptyDescription, - )) => { - println!("aborted new comment"); - return Ok(()); - } - Err(e) => { - return Err(e.into()); - } - Ok(comment) => { - println!( - "created new comment {} on issue {}", - &comment.uuid, &issue_id - ); - } - } - } - - Commands::Sync { remote } => { - if let entomologist::database::IssuesDatabaseSource::Branch(branch) = - issues_database_source - { - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::IssuesDatabaseAccess::ReadWrite, - )?; - entomologist::git::sync(&issues_database.dir, remote, branch)?; - println!("synced {:?} with {:?}", branch, remote); - } else { - return Err(anyhow::anyhow!( - "`sync` operates on a branch, don't specify `issues_dir`" - )); - } - } - - Commands::Assign { - issue_id, - new_assignee, - } => match new_assignee { - Some(new_assignee) => { - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::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)); - }; - let old_assignee: String = match &issue.assignee { - Some(assignee) => assignee.clone(), - None => String::from("None"), - }; - issue.set_assignee(new_assignee)?; - println!("issue: {}", issue_id); - println!("assignee: {} -> {}", old_assignee, new_assignee); - } - None => { - let issues = entomologist::database::read_issues_database(issues_database_source)?; - let Some(original_issue) = issues.issues.get(issue_id) else { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); - }; - let old_assignee: String = match &original_issue.assignee { - Some(assignee) => assignee.clone(), - None => String::from("None"), - }; - println!("issue: {}", issue_id); - println!("assignee: {}", old_assignee); - } - }, - - Commands::Tag { issue_id, tag } => match tag { - Some(tag) => { - // Add or remove tag. - if tag.len() == 0 { - return Err(anyhow::anyhow!("invalid zero-length tag")); - } - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::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.chars().nth(0).unwrap() == '-' { - let tag = &tag[1..]; - issue.remove_tag(tag)?; - } else { - issue.add_tag(tag)?; - } - } - None => { - // Just list the tags. - let issues = entomologist::database::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 &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); - } - } - } - } - }, - - Commands::DoneTime { - issue_id, - done_time, - } => match done_time { - Some(done_time) => { - // Add or remove tag. - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::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)); - }; - let done_time = match chrono::DateTime::parse_from_rfc3339(done_time) { - Ok(done_time) => done_time.with_timezone(&chrono::Local), - Err(e) => { - eprintln!("failed to parse done-time from {}", done_time); - return Err(e.into()); - } - }; - issue.set_done_time(done_time)?; - } - None => { - let issues = entomologist::database::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 &issue.done_time { - Some(done_time) => println!("done_time: {}", done_time), - None => println!("None"), - }; - } - }, - - Commands::Depend { - issue_id, - dependency_id, - } => match dependency_id { - Some(dep_id) => { - let ent_db = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::IssuesDatabaseAccess::ReadWrite, - )?; - let mut issues = entomologist::issues::Issues::new_from_dir(&ent_db.dir)?; - if issues.issues.contains_key(dep_id) { - if let Some(issue) = issues.issues.get_mut(issue_id) { - issue.add_dependency(dep_id.clone())?; - } else { - Err(anyhow::anyhow!("issue {} not found", issue_id))?; - }; - } else { - Err(anyhow::anyhow!("dependency {} not found", dep_id))?; - }; - } - None => { - let ent_db = entomologist::database::read_issues_database(issues_database_source)?; - - let Some(issue) = ent_db.issues.get(issue_id) else { - Err(anyhow::anyhow!("issue {} not found", issue_id))? - }; - println!("DEPENDENCIES:"); - if let Some(list) = &issue.dependencies { - for dependency in list { - println!("{}", dependency); - } - } else { - println!("NONE"); - } - } - }, - } - - Ok(()) -} - -fn main() -> anyhow::Result<()> { - #[cfg(feature = "log")] - simple_logger::SimpleLogger::new().env().init().unwrap(); - - let args: Args = Args::parse(); - // println!("{:?}", args); - - let issues_database_source = match (&args.issues_dir, &args.issues_branch) { - (Some(dir), None) => { - entomologist::database::IssuesDatabaseSource::Dir(std::path::Path::new(dir)) - } - (None, Some(branch)) => entomologist::database::IssuesDatabaseSource::Branch(branch), - (None, None) => entomologist::database::IssuesDatabaseSource::Branch("entomologist-data"), - (Some(_), Some(_)) => { - return Err(anyhow::anyhow!( - "don't specify both `--issues-dir` and `--issues-branch`" - )); - } - }; - - if let entomologist::database::IssuesDatabaseSource::Branch(branch) = &issues_database_source { - if !entomologist::git::git_branch_exists(branch)? { - entomologist::git::create_orphan_branch(branch)?; - } - } - - handle_command(&args, &issues_database_source)?; - - Ok(()) -} diff --git a/src/comment.rs b/src/comment.rs deleted file mode 100644 index 1fa2e36..0000000 --- a/src/comment.rs +++ /dev/null @@ -1,230 +0,0 @@ -use std::io::{IsTerminal, Write}; - -#[derive(Debug, PartialEq)] -pub struct Comment { - pub uuid: String, - pub author: String, - pub creation_time: chrono::DateTime, - 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(transparent)] - EnvVarError(#[from] std::env::VarError), - #[error("Failed to parse comment")] - CommentParseError, - #[error("Failed to run git")] - GitError(#[from] crate::git::GitError), - #[error("Failed to run editor")] - EditorError, - #[error("supplied description is empty")] - EmptyDescription, - #[error("stdin/stdout is not a terminal")] - StdioIsNotTerminal, -} - -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 - ); - } - } - } - let Some(description) = description else { - return Err(CommentError::CommentParseError); - }; - - let (author, creation_time) = crate::git::git_log_oldest_author_timestamp(comment_dir)?; - - let dir = std::path::PathBuf::from(comment_dir); - - Ok(Self { - uuid: String::from( - dir.file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - ), - author, - creation_time, - description, - dir: std::path::PathBuf::from(comment_dir), - }) - } - - /// Create a new Comment on the specified Issue. Commits. - pub fn new( - issue: &crate::issue::Issue, - description: &Option, - ) -> Result { - let mut dir = std::path::PathBuf::from(&issue.dir); - dir.push("comments"); - if !dir.exists() { - std::fs::create_dir(&dir)?; - } - - let rnd: u128 = rand::random(); - let uuid = format!("{:032x}", rnd); - dir.push(&uuid); - std::fs::create_dir(&dir)?; - - let mut comment = crate::comment::Comment { - uuid, - author: String::from(""), // this will be updated from git when we re-read this comment - creation_time: chrono::Local::now(), - description: String::from(""), // this will be set immediately below - dir: dir.clone(), - }; - - match description { - Some(description) => { - if description.len() == 0 { - return Err(CommentError::EmptyDescription); - } - comment.description = String::from(description); - let description_filename = comment.description_filename(); - let mut description_file = std::fs::File::create(&description_filename)?; - write!(description_file, "{}", description)?; - } - None => comment.edit_description_file()?, - }; - - crate::git::add(&dir)?; - if crate::git::worktree_is_dirty(&dir.to_string_lossy())? { - crate::git::commit( - &dir, - &format!( - "add comment {} on issue {}", - comment.uuid, - issue - .dir - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - ), - )?; - } - - Ok(comment) - } - - 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> { - self.edit_description_file()?; - let description_filename = self.description_filename(); - crate::git::add(&description_filename)?; - if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { - crate::git::commit( - &description_filename - .parent() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?, - &format!( - "edit comment {} on issue FIXME", // FIXME: name the issue that the comment is on - self.dir - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy() - ), - )?; - self.read_description()?; - } - Ok(()) - } - - /// Opens the Comment's `description` file in an editor. Validates - /// the editor's exit code. Updates the Comment's internal - /// description from what the user saved in the file. - /// - /// Used by Issue::add_comment() when no description is supplied, - /// and (FIXME: in the future) used by `ent edit COMMENT`. - pub fn edit_description_file(&mut self) -> Result<(), CommentError> { - if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { - return Err(CommentError::StdioIsNotTerminal); - } - - let description_filename = self.description_filename(); - let exists = description_filename.exists(); - - let editor = match std::env::var("EDITOR") { - Ok(editor) => editor, - Err(std::env::VarError::NotPresent) => String::from("vi"), - Err(e) => return Err(e.into()), - }; - let result = std::process::Command::new(editor) - .arg(&description_filename.as_os_str()) - .spawn()? - .wait_with_output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(CommentError::EditorError); - } - - if !description_filename.exists() || description_filename.metadata()?.len() == 0 { - // User saved an empty file, which means they changed their - // mind and no longer want to edit the description. - if exists { - crate::git::restore_file(&description_filename)?; - } - return Err(CommentError::EmptyDescription); - } - self.read_description()?; - Ok(()) - } -} - -// This is the private, internal API. -impl Comment { - fn description_filename(&self) -> std::path::PathBuf { - let mut description_filename = std::path::PathBuf::from(&self.dir); - description_filename.push("description"); - description_filename - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn read_comment_0() { - let comment_dir = std::path::Path::new( - "test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347", - ); - let comment = Comment::new_from_dir(comment_dir).unwrap(); - let expected = Comment { - uuid: String::from("9055dac36045fe36545bed7ae7b49347"), - author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T10:08:38-06:00") - .unwrap() - .with_timezone(&chrono::Local), - description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe\n\nIt has multiple lines\n"), - dir: std::path::PathBuf::from(comment_dir), - }; - assert_eq!(comment, expected); - } -} diff --git a/src/database.rs b/src/database.rs deleted file mode 100644 index 8afcd8d..0000000 --- a/src/database.rs +++ /dev/null @@ -1,94 +0,0 @@ -use thiserror::Error; -use crate::{git::GitError, issues::ReadIssuesError}; - -/// Errors that the DB can emit: -#[derive(Debug, Error)] -pub enum Error { - #[error(transparent)] - IssuesError(#[from] ReadIssuesError), - #[error(transparent)] - GitError(#[from] GitError), -} - - -/// The main function looks at the command-line arguments and determines -/// from there where to get the Issues Database to operate on. -/// -/// * If the user specified `--issues-dir` we use that. -/// -/// * If the user specified `--issues-branch` we make sure the branch -/// exists, then use that. -/// -/// * If the user specified neither, we use the default branch -/// `entomologist-data` (after ensuring that it exists). -/// -/// * If the user specified both, it's an operator error and we abort. -/// -/// The result of that code populates an IssuesDatabaseSource object, -/// that gets used later to access the database. -pub enum IssuesDatabaseSource<'a> { - Dir(&'a std::path::Path), - Branch(&'a str), -} - - - -/// The IssuesDatabase type is a "fat path". It holds a PathBuf pointing -/// at the issues database directory, and optionally a Worktree object -/// corresponding to that path. -/// -/// The worktree field itself is never read: we put its path in `dir` -/// and that's all that the calling code cares about. -/// -/// The Worktree object is included here *when* the IssuesDatabaseSource -/// is a branch. In this case a git worktree is created to hold the -/// checkout of the branch. When the IssueDatabase object is dropped, -/// the contained/owned Worktree object is dropped, which deletes the -/// worktree directory from the filesystem and prunes the worktree from -/// git's worktree list. - -pub struct IssuesDatabase { - pub dir: std::path::PathBuf, - - #[allow(dead_code)] - pub worktree: Option, -} - -pub enum IssuesDatabaseAccess { - ReadOnly, - ReadWrite, -} - -pub fn make_issues_database( - issues_database_source: &IssuesDatabaseSource, - access_type: IssuesDatabaseAccess, -) -> Result { - match issues_database_source { - IssuesDatabaseSource::Dir(dir) => Ok(IssuesDatabase { - dir: std::path::PathBuf::from(dir), - worktree: None, - }), - IssuesDatabaseSource::Branch(branch) => { - let worktree = match access_type { - IssuesDatabaseAccess::ReadOnly => { - crate::git::Worktree::new_detached(branch)? - } - IssuesDatabaseAccess::ReadWrite => crate::git::Worktree::new(branch)?, - }; - Ok(IssuesDatabase { - dir: std::path::PathBuf::from(worktree.path()), - worktree: Some(worktree), - }) - } - } -} - -pub fn read_issues_database( - issues_database_source: &IssuesDatabaseSource, -) -> Result { - let issues_database = - make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadOnly)?; - Ok(crate::issues::Issues::new_from_dir( - &issues_database.dir, - )?) -} \ No newline at end of file diff --git a/src/git.rs b/src/git.rs deleted file mode 100644 index 6e70fa8..0000000 --- a/src/git.rs +++ /dev/null @@ -1,547 +0,0 @@ -use std::io::Write; - -#[derive(Debug, thiserror::Error)] -pub enum GitError { - #[error(transparent)] - StdIoError(#[from] std::io::Error), - #[error(transparent)] - ParseIntError(#[from] std::num::ParseIntError), - #[error("Oops, something went wrong")] - Oops, -} - -#[derive(Debug)] -/// `Worktree` is a struct that manages a temporary directory containing -/// a checkout of a specific branch. The worktree is removed and pruned -/// when the `Worktree` struct is dropped. -pub struct Worktree { - path: tempfile::TempDir, -} - -impl Drop for Worktree { - fn drop(&mut self) { - let result = std::process::Command::new("git") - .args([ - "worktree", - "remove", - "--force", - &self.path.path().to_string_lossy(), - ]) - .output(); - match result { - Err(e) => { - println!("failed to run git: {:#?}", e); - } - Ok(result) => { - if !result.status.success() { - println!("failed to remove git worktree: {:#?}", result); - } - } - } - } -} - -impl Worktree { - pub fn new(branch: &str) -> Result { - let path = tempfile::tempdir()?; - let result = std::process::Command::new("git") - .args(["worktree", "add", &path.path().to_string_lossy(), branch]) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - Ok(Self { path }) - } - - pub fn new_detached(branch: &str) -> Result { - let path = tempfile::tempdir()?; - let result = std::process::Command::new("git") - .args([ - "worktree", - "add", - "--detach", - &path.path().to_string_lossy(), - branch, - ]) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - Ok(Self { path }) - } - - pub fn path(&self) -> &std::path::Path { - self.path.as_ref() - } -} - -pub fn checkout_branch_in_worktree( - branch: &str, - worktree_dir: &std::path::Path, -) -> Result<(), GitError> { - let result = std::process::Command::new("git") - .args(["worktree", "add", &worktree_dir.to_string_lossy(), branch]) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - Ok(()) -} - -pub fn git_worktree_prune() -> Result<(), GitError> { - let result = std::process::Command::new("git") - .args(["worktree", "prune"]) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - Ok(()) -} - -pub fn git_remove_branch(branch: &str) -> Result<(), GitError> { - let result = std::process::Command::new("git") - .args(["branch", "-D", branch]) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - Ok(()) -} - -pub fn git_branch_exists(branch: &str) -> Result { - let result = std::process::Command::new("git") - .args(["show-ref", "--quiet", branch]) - .output()?; - return Ok(result.status.success()); -} - -pub fn worktree_is_dirty(dir: &str) -> Result { - // `git status --porcelain` prints a terse list of files added or - // modified (both staged and not), and new untracked files. So if - // says *anything at all* it means the worktree is dirty. - let result = std::process::Command::new("git") - .args(["status", "--porcelain", "--untracked-files=no"]) - .current_dir(dir) - .output()?; - return Ok(result.stdout.len() > 0); -} - -pub fn add(file: &std::path::Path) -> Result<(), GitError> { - let result = std::process::Command::new("git") - .args(["add", &file.to_string_lossy()]) - .current_dir( - file.parent() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?, - ) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - return Ok(()); -} - -pub fn restore_file(file: &std::path::Path) -> Result<(), GitError> { - let result = std::process::Command::new("git") - .args(["restore", &file.to_string_lossy()]) - .current_dir( - file.parent() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?, - ) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - return Ok(()); -} - -pub fn commit(dir: &std::path::Path, msg: &str) -> Result<(), GitError> { - let result = std::process::Command::new("git") - .args(["commit", "-m", msg]) - .current_dir(dir) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - Ok(()) -} - -pub fn git_commit_file(file: &std::path::Path) -> Result<(), GitError> { - let mut git_dir = std::path::PathBuf::from(file); - git_dir.pop(); - - let result = std::process::Command::new("git") - .args([ - "add", - &file - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - ]) - .current_dir(&git_dir) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - - let result = std::process::Command::new("git") - .args([ - "commit", - "-m", - &format!( - "update '{}' in issue {}", - file.file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - git_dir - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy() - ), - ]) - .current_dir(&git_dir) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - - Ok(()) -} - -pub fn git_fetch(dir: &std::path::Path, remote: &str) -> Result<(), GitError> { - let result = std::process::Command::new("git") - .args(["fetch", remote]) - .current_dir(dir) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - Ok(()) -} - -pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), GitError> { - // We do all the work in a directory that's (FIXME) hopefully a - // worktree. If anything goes wrong we just fail out and ask the - // human to fix it by hand :-/ - // 1. `git fetch` - // 2. `git merge REMOTE/BRANCH` - // 3. `git push REMOTE BRANCH` - - git_fetch(dir, remote)?; - - // FIXME: Possible things to add: - // * `git log -p` shows diff - // * `git log --numstat` shows machine-readable diffstat - - // Show what we just fetched from the remote. - let result = std::process::Command::new("git") - .args([ - "log", - "--no-merges", - "--pretty=format:%an: %s", - &format!("{}/{}", remote, branch), - &format!("^{}", branch), - ]) - .current_dir(dir) - .output()?; - if !result.status.success() { - println!( - "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}", - branch - ); - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - if result.stdout.len() > 0 { - println!("Changes fetched from remote {}:", remote); - println!("{}", &String::from_utf8_lossy(&result.stdout)); - println!(""); - } - - // Show what we are about to push to the remote. - let result = std::process::Command::new("git") - .args([ - "log", - "--no-merges", - "--pretty=format:%an: %s", - &format!("{}", branch), - &format!("^{}/{}", remote, branch), - ]) - .current_dir(dir) - .output()?; - if !result.status.success() { - println!( - "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}", - branch - ); - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - if result.stdout.len() > 0 { - println!("Changes to push to remote {}:", remote); - println!("{}", &String::from_utf8_lossy(&result.stdout)); - println!(""); - } - - // Merge remote branch into local. - let result = std::process::Command::new("git") - .args(["merge", &format!("{}/{}", remote, branch)]) - .current_dir(dir) - .output()?; - if !result.status.success() { - println!( - "Sync failed! Merge error! Help, a human needs to fix the mess in {:?}", - branch - ); - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - - // Push merged branch to remote. - let result = std::process::Command::new("git") - .args(["push", remote, branch]) - .current_dir(dir) - .output()?; - if !result.status.success() { - println!( - "Sync failed! Push error! Help, a human needs to fix the mess in {:?}", - branch - ); - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - - Ok(()) -} - -pub fn git_log_oldest_timestamp( - path: &std::path::Path, -) -> Result, GitError> { - let mut git_dir = std::path::PathBuf::from(path); - git_dir.pop(); - let result = std::process::Command::new("git") - .args([ - "log", - "--pretty=format:%at", - "--", - &path - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - ]) - .current_dir(&git_dir) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - let timestamp_str = std::str::from_utf8(&result.stdout).unwrap(); - let timestamp_last = timestamp_str.split("\n").last().unwrap(); - let timestamp_i64 = timestamp_last.parse::()?; - let timestamp = chrono::DateTime::from_timestamp(timestamp_i64, 0) - .unwrap() - .with_timezone(&chrono::Local); - Ok(timestamp) -} - -pub fn git_log_oldest_author(path: &std::path::Path) -> Result { - let mut git_dir = std::path::PathBuf::from(path); - git_dir.pop(); - let result = std::process::Command::new("git") - .args([ - "log", - "--pretty=format:%an <%ae>", - "--", - &path - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - ]) - .current_dir(&git_dir) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - let author_str = std::str::from_utf8(&result.stdout).unwrap(); - let author_last = author_str.split("\n").last().unwrap(); - Ok(String::from(author_last)) -} - -pub fn git_log_oldest_author_timestamp( - path: &std::path::Path, -) -> Result<(String, chrono::DateTime), GitError> { - let mut git_dir = std::path::PathBuf::from(path); - git_dir.pop(); - let result = std::process::Command::new("git") - .args([ - "log", - "--pretty=format:%at %an <%ae>", - "--", - &path - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - ]) - .current_dir(&git_dir) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - - let raw_output_str = String::from_utf8_lossy(&result.stdout); - let Some(raw_output_last) = raw_output_str.split("\n").last() else { - return Err(GitError::Oops); - }; - let Some(index) = raw_output_last.find(' ') else { - return Err(GitError::Oops); - }; - let author_str = &raw_output_last[index + 1..]; - let timestamp_str = &raw_output_last[0..index]; - let timestamp_i64 = timestamp_str.parse::()?; - let timestamp = chrono::DateTime::from_timestamp(timestamp_i64, 0) - .unwrap() - .with_timezone(&chrono::Local); - - Ok((String::from(author_str), timestamp)) -} - -pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> { - { - let tmp_worktree = tempfile::tempdir().unwrap(); - create_orphan_branch_at_path(branch, tmp_worktree.path())?; - } - // The temp dir is now removed / cleaned up. - - let result = std::process::Command::new("git") - .args(["worktree", "prune"]) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - - Ok(()) -} - -fn create_orphan_branch_at_path( - branch: &str, - worktree_path: &std::path::Path, -) -> Result<(), GitError> { - let worktree_dir = worktree_path.to_string_lossy(); - let result = std::process::Command::new("git") - .args(["worktree", "add", "--orphan", "-b", branch, &worktree_dir]) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - - let mut readme_filename = std::path::PathBuf::from(worktree_path); - readme_filename.push("README.md"); - let mut readme = std::fs::File::create(readme_filename)?; - write!( - readme, - "This branch is used by entomologist to track issues." - )?; - - let result = std::process::Command::new("git") - .args(["add", "README.md"]) - .current_dir(worktree_path) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - - let result = std::process::Command::new("git") - .args(["commit", "-m", "create entomologist issue branch"]) - .current_dir(worktree_path) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn test_worktree() { - let mut p = std::path::PathBuf::new(); - { - let worktree = Worktree::new("origin/main").unwrap(); - - p.push(worktree.path()); - assert!(p.exists()); - - let mut p2 = p.clone(); - p2.push("README.md"); - assert!(p2.exists()); - } - // The temporary worktree directory is removed when the Temp variable is dropped. - assert!(!p.exists()); - } - - #[test] - fn test_create_orphan_branch() { - let rnd: u128 = rand::random(); - let mut branch = std::string::String::from("entomologist-test-branch-"); - branch.push_str(&format!("{:032x}", rnd)); - create_orphan_branch(&branch).unwrap(); - git_remove_branch(&branch).unwrap(); - } - - #[test] - fn test_branch_exists_0() { - let r = git_branch_exists("main").unwrap(); - assert_eq!(r, true); - } - - #[test] - fn test_branch_exists_1() { - let rnd: u128 = rand::random(); - let mut branch = std::string::String::from("entomologist-missing-branch-"); - branch.push_str(&format!("{:032x}", rnd)); - let r = git_branch_exists(&branch).unwrap(); - assert_eq!(r, false); - } -} diff --git a/src/issue.rs b/src/issue.rs deleted file mode 100644 index 06f959f..0000000 --- a/src/issue.rs +++ /dev/null @@ -1,603 +0,0 @@ -use core::fmt; -use std::io::{IsTerminal, Write}; -use std::str::FromStr; - -#[cfg(feature = "log")] -use log::debug; - -#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Deserialize)] -/// These are the states an issue can be in. -pub enum State { - New, - Backlog, - Blocked, - InProgress, - Done, - WontDo, -} - -pub type IssueHandle = String; - -#[derive(Debug, PartialEq)] -pub struct Issue { - pub id: String, - pub author: String, - pub creation_time: chrono::DateTime, - pub done_time: Option>, - pub tags: Vec, - pub state: State, - pub dependencies: Option>, - pub assignee: Option, - pub description: String, - pub comments: Vec, - - /// This is the directory that the issue lives in. Only used - /// internally by the entomologist library. - pub dir: std::path::PathBuf, -} - -#[derive(Debug, thiserror::Error)] -pub enum IssueError { - #[error(transparent)] - StdIoError(#[from] std::io::Error), - #[error(transparent)] - EnvVarError(#[from] std::env::VarError), - #[error(transparent)] - CommentError(#[from] crate::comment::CommentError), - #[error(transparent)] - ChronoParseError(#[from] chrono::format::ParseError), - #[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")] - EditorError, - #[error("supplied description is empty")] - EmptyDescription, - #[error("tag {0} not found")] - TagNotFound(String), - #[error("stdin/stdout is not a terminal")] - StdioIsNotTerminal, - #[error("Failed to parse issue ID")] - IdError, - #[error("Dependency not found")] - DepNotFound, - #[error("Dependency already exists")] - DepExists, - #[error("Self-dependency not allowed")] - DepSelf, -} - -impl FromStr for State { - type Err = IssueError; - fn from_str(s: &str) -> Result { - let s = s.to_lowercase(); - if s == "new" { - Ok(State::New) - } else if s == "backlog" { - Ok(State::Backlog) - } else if s == "blocked" { - Ok(State::Blocked) - } else if s == "inprogress" { - Ok(State::InProgress) - } else if s == "done" { - Ok(State::Done) - } else if s == "wontdo" { - Ok(State::WontDo) - } else { - Err(IssueError::StateParseError) - } - } -} - -impl fmt::Display for State { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let fmt_str = match self { - State::New => "new", - State::Backlog => "backlog", - State::Blocked => "blocked", - State::InProgress => "inprogress", - State::Done => "done", - State::WontDo => "wontdo", - }; - write!(f, "{fmt_str}") - } -} - -// This is the public API of Issue. -impl Issue { - pub fn new_from_dir(dir: &std::path::Path) -> Result { - 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 = Vec::::new(); - let mut assignee: Option = None; - let mut tags = Vec::::new(); - let mut done_time: Option> = None; - - for direntry in 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 if file_name == "state" { - let state_string = std::fs::read_to_string(direntry.path())?; - state = State::from_str(state_string.trim())?; - } else if file_name == "assignee" { - assignee = Some(String::from( - std::fs::read_to_string(direntry.path())?.trim(), - )); - } else if file_name == "done_time" { - let raw_done_time = chrono::DateTime::<_>::parse_from_rfc3339( - std::fs::read_to_string(direntry.path())?.trim(), - )?; - done_time = Some(raw_done_time.into()); - } else if file_name == "dependencies" && direntry.metadata()?.is_dir() { - dependencies = Self::read_dependencies(&direntry.path())?; - } 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(); - tags.sort(); - } 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); - } - } - } - - let Some(description) = description else { - return Err(IssueError::IssueParseError); - }; - - // parse the issue ID from the directory name - let id = if let Some(parsed_id) = match dir.file_name() { - Some(name) => name.to_str(), - None => Err(IssueError::IdError)?, - } { - String::from(parsed_id) - } else { - Err(IssueError::IdError)? - }; - - let (author, creation_time) = crate::git::git_log_oldest_author_timestamp(dir)?; - - Ok(Self { - id, - author, - creation_time, - done_time, - tags, - state: state, - dependencies, - assignee, - description, - comments, - dir: std::path::PathBuf::from(dir), - }) - } - - fn read_comments( - comments: &mut Vec, - dir: &std::path::Path, - ) -> Result<(), IssueError> { - for direntry in dir.read_dir()? { - if let Ok(direntry) = direntry { - let comment = crate::comment::Comment::new_from_dir(&direntry.path())?; - comments.push(comment); - } - } - comments.sort_by(|a, b| a.creation_time.cmp(&b.creation_time)); - Ok(()) - } - - fn read_dependencies(dir: &std::path::Path) -> Result>, IssueError> { - let mut dependencies: Option> = None; - for direntry in dir.read_dir()? { - if let Ok(direntry) = direntry { - match &mut dependencies { - Some(deps) => { - deps.push(direntry.file_name().into_string().unwrap()); - } - None => { - dependencies = Some(vec![direntry.file_name().into_string().unwrap()]); - } - } - } - } - if let Some(deps) = &mut dependencies { - deps.sort(); - } - Ok(dependencies) - } - - /// Add a new Comment to the Issue. Commits. - pub fn add_comment( - &mut self, - description: &Option, - ) -> Result { - let comment = crate::comment::Comment::new(self, description)?; - Ok(comment) - } - - /// Create a new Issue in an Issues database specified by a directory. - /// The new Issue will live in a new subdirectory, named by a unique - /// Issue identifier. - /// - /// If a description string is supplied, the new Issue's description - /// will be initialized from it with no user interaction. - /// - /// If no description is supplied, the user will be prompted to - /// input one into an editor. - /// - /// On success, the new Issue with its valid description is committed - /// to the Issues database. - pub fn new(dir: &std::path::Path, description: &Option) -> Result { - let mut issue_dir = std::path::PathBuf::from(dir); - let rnd: u128 = rand::random(); - let issue_id = format!("{:032x}", rnd); - issue_dir.push(&issue_id); - std::fs::create_dir(&issue_dir)?; - - let mut issue = Self { - id: String::from(&issue_id), - author: String::from(""), - creation_time: chrono::Local::now(), - done_time: None, - tags: Vec::::new(), - state: State::New, - dependencies: None, - assignee: None, - description: String::from(""), // FIXME: kind of bogus to use the empty string as None - comments: Vec::::new(), - dir: issue_dir.clone(), - }; - - match description { - Some(description) => { - if description.len() == 0 { - return Err(IssueError::EmptyDescription); - } - issue.description = String::from(description); - let description_filename = issue.description_filename(); - let mut description_file = std::fs::File::create(&description_filename)?; - write!(description_file, "{}", description)?; - } - None => issue.edit_description_file()?, - }; - - issue.commit(&format!("create new issue {}", issue_id))?; - - Ok(issue) - } - - /// Interactively edit the description of an existing Issue. - pub fn edit_description(&mut self) -> Result<(), IssueError> { - self.edit_description_file()?; - let description_filename = self.description_filename(); - self.commit(&format!( - "edit description of issue {}", - description_filename - .parent() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - ))?; - Ok(()) - } - - /// Return the Issue title (first line of the description). - pub fn title<'a>(&'a self) -> &'a str { - match self.description.find("\n") { - Some(index) => &self.description.as_str()[..index], - None => self.description.as_str(), - } - } - - /// Change the State of the Issue. If the new state is `Done`, - /// set the Issue `done_time`. Commits. - pub fn set_state(&mut self, new_state: State) -> Result<(), IssueError> { - let old_state = self.state.clone(); - let mut state_filename = std::path::PathBuf::from(&self.dir); - state_filename.push("state"); - let mut state_file = std::fs::File::create(&state_filename)?; - write!(state_file, "{}", new_state)?; - self.commit(&format!( - "change state of issue {}, {} -> {}", - self.dir - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - old_state, - new_state, - ))?; - if new_state == State::Done { - self.set_done_time(chrono::Local::now())?; - } - Ok(()) - } - - pub fn read_state(&mut self) -> Result<(), IssueError> { - let mut state_filename = std::path::PathBuf::from(&self.dir); - state_filename.push("state"); - let state_string = std::fs::read_to_string(state_filename)?; - self.state = State::from_str(state_string.trim())?; - Ok(()) - } - - /// Set the `done_time` of the Issue. Commits. - pub fn set_done_time( - &mut self, - done_time: chrono::DateTime, - ) -> Result<(), IssueError> { - let mut done_time_filename = std::path::PathBuf::from(&self.dir); - done_time_filename.push("done_time"); - let mut done_time_file = std::fs::File::create(&done_time_filename)?; - write!(done_time_file, "{}", done_time.to_rfc3339())?; - self.done_time = Some(done_time.clone()); - self.commit(&format!( - "set done-time of issue {} to {}", - self.dir - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - done_time, - ))?; - Ok(()) - } - - /// Set the Assignee of an Issue. - pub fn set_assignee(&mut self, new_assignee: &str) -> Result<(), IssueError> { - let old_assignee = match &self.assignee { - Some(assignee) => assignee.clone(), - None => String::from("None"), - }; - let mut assignee_filename = std::path::PathBuf::from(&self.dir); - assignee_filename.push("assignee"); - let mut assignee_file = std::fs::File::create(&assignee_filename)?; - write!(assignee_file, "{}", new_assignee)?; - self.commit(&format!( - "change assignee of issue {}, {} -> {}", - self.dir - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - old_assignee, - new_assignee, - ))?; - 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() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .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() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .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; - } - - pub fn add_dependency(&mut self, dep: IssueHandle) -> Result<(), IssueError> { - if self.id == dep { - Err(IssueError::DepSelf)?; - } - match &mut self.dependencies { - Some(v) => v.push(dep.clone()), - None => self.dependencies = Some(vec![dep.clone()]), - } - let mut dir = std::path::PathBuf::from(&self.dir); - dir.push("dependencies"); - if !dir.exists() { - std::fs::create_dir(&dir)?; - } - - dir.push(dep.clone()); - - if !dir.exists() { - std::fs::File::create(&dir)?; - self.commit(&format!("add dep {} to issue {}", dep, self.id))?; - } else { - Err(IssueError::DepExists)?; - } - Ok(()) - } - - pub fn remove_dependency(&mut self, dep: IssueHandle) -> Result<(), IssueError> { - match &mut self.dependencies { - Some(v) => { - if let Some(i) = v.iter().position(|d| d == &dep) { - v.remove(i); - } else { - Err(IssueError::DepNotFound)?; - } - } - None => Err(IssueError::DepNotFound)?, - } - self.commit(&format!("remove dep {} from issue {}", dep, self.id))?; - Ok(()) - } -} - -// This is the internal/private API of Issue. -impl Issue { - fn description_filename(&self) -> std::path::PathBuf { - let mut description_filename = std::path::PathBuf::from(&self.dir); - description_filename.push("description"); - description_filename - } - - /// Read the Issue's description file into the internal Issue representation. - fn read_description(&mut self) -> Result<(), IssueError> { - let description_filename = self.description_filename(); - self.description = std::fs::read_to_string(description_filename)?; - Ok(()) - } - - /// Opens the Issue's `description` file in an editor. Validates the - /// editor's exit code. Updates the Issue's internal description - /// from what the user saved in the file. - /// - /// Used by Issue::new() when no description is supplied, and also - /// used by `ent edit ISSUE`. - fn edit_description_file(&mut self) -> Result<(), IssueError> { - if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { - return Err(IssueError::StdioIsNotTerminal); - } - - let description_filename = self.description_filename(); - let exists = description_filename.exists(); - let editor = match std::env::var("EDITOR") { - Ok(editor) => editor, - Err(std::env::VarError::NotPresent) => String::from("vi"), - Err(e) => return Err(e.into()), - }; - let result = std::process::Command::new(editor) - .arg(&description_filename.as_os_str()) - .spawn()? - .wait_with_output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(IssueError::EditorError); - } - if !description_filename.exists() || description_filename.metadata()?.len() == 0 { - // User saved an empty file, or exited without saving while - // editing a new description file. Both means they changed - // their mind and no longer want to edit the description. - if exists { - // File existed before the user emptied it, so restore - // the original. - crate::git::restore_file(&description_filename)?; - } - return Err(IssueError::EmptyDescription); - } - 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)?; - } - self.commit(commit_message)?; - Ok(()) - } - - fn commit(&self, commit_message: &str) -> Result<(), IssueError> { - crate::git::add(&self.dir)?; - if !crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { - return Ok(()); - } - crate::git::commit(&self.dir, commit_message)?; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn read_issue_0() { - let issue_dir = std::path::Path::new("test/0000/3943fc5c173fdf41c0a22251593cd476/"); - let issue = Issue::new_from_dir(issue_dir).unwrap(); - let expected = Issue { - id: String::from("3943fc5c173fdf41c0a22251593cd476"), - author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:36:25-06:00") - .unwrap() - .with_timezone(&chrono::Local), - done_time: None, - tags: Vec::::from([ - String::from("TAG2"), - String::from("i-am-also-a-tag"), - String::from("tag1"), - ]), - state: State::New, - dependencies: None, - assignee: None, - 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", - ), - comments: Vec::::new(), - dir: std::path::PathBuf::from(issue_dir), - }; - assert_eq!(issue, expected); - } - - #[test] - fn read_issue_1() { - let issue_dir = std::path::Path::new("test/0000/7792b063eef6d33e7da5dc1856750c14/"); - let issue = Issue::new_from_dir(issue_dir).unwrap(); - let expected = Issue { - id: String::from("7792b063eef6d33e7da5dc1856750c14"), - author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:37:07-06:00") - .unwrap() - .with_timezone(&chrono::Local), - done_time: None, - tags: Vec::::new(), - state: State::InProgress, - dependencies: None, - assignee: Some(String::from("beep boop")), - description: String::from("minimal"), - comments: Vec::::new(), - dir: std::path::PathBuf::from(issue_dir), - }; - assert_eq!(issue, expected); - } -} diff --git a/src/issues.rs b/src/issues.rs deleted file mode 100644 index d3c57c0..0000000 --- a/src/issues.rs +++ /dev/null @@ -1,284 +0,0 @@ -#[cfg(feature = "log")] -use log::debug; - -// Just a placeholder for now, get rid of this if we don't need it. -#[derive(Debug, PartialEq, serde::Deserialize)] -pub struct Config {} - -#[derive(Debug, PartialEq)] -pub struct Issues { - pub issues: std::collections::HashMap, - pub config: Config, -} - -#[derive(Debug, thiserror::Error)] -pub enum ReadIssuesError { - #[error(transparent)] - StdIoError(#[from] std::io::Error), - #[error(transparent)] - IssueError(#[from] crate::issue::IssueError), - #[error("cannot handle filename")] - FilenameError(std::ffi::OsString), - #[error(transparent)] - TomlDeserializeError(#[from] toml::de::Error), -} - -impl Issues { - pub fn new() -> Self { - Self { - issues: std::collections::HashMap::new(), - config: Config {}, - } - } - - pub fn add_issue(&mut self, issue: crate::issue::Issue) { - self.issues.insert(issue.id.clone(), issue); - } - - pub fn get_issue(&self, issue_id: &str) -> Option<&crate::issue::Issue> { - self.issues.get(issue_id) - } - - pub fn get_mut_issue(&mut self, issue_id: &str) -> Option<&mut crate::issue::Issue> { - self.issues.get_mut(issue_id) - } - - fn parse_config(&mut self, config_path: &std::path::Path) -> Result<(), ReadIssuesError> { - let config_contents = std::fs::read_to_string(config_path)?; - let config: Config = toml::from_str(&config_contents)?; - self.config = config; - Ok(()) - } - - pub fn new_from_dir(dir: &std::path::Path) -> Result { - let mut issues = Self::new(); - - for direntry in dir.read_dir()? { - if let Ok(direntry) = direntry { - if direntry.metadata()?.is_dir() { - match crate::issue::Issue::new_from_dir(direntry.path().as_path()) { - Err(e) => { - eprintln!( - "failed to parse issue {}, skipping", - direntry.file_name().to_string_lossy() - ); - eprintln!("ignoring error: {:?}", e); - continue; - } - Ok(issue) => { - issues.add_issue(issue); - } - } - } else if direntry.file_name() == "config.toml" { - issues.parse_config(direntry.path().as_path())?; - } else { - #[cfg(feature = "log")] - debug!( - "ignoring unknown file in issues directory: {:?}", - direntry.file_name() - ); - } - } - } - return Ok(issues); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn read_issues_0000() { - let issues_dir = std::path::Path::new("test/0000/"); - let issues = Issues::new_from_dir(issues_dir).unwrap(); - - let mut expected = Issues::new(); - - let uuid = String::from("7792b063eef6d33e7da5dc1856750c14"); - let mut dir = std::path::PathBuf::from(issues_dir); - dir.push(&uuid); - expected.add_issue(crate::issue::Issue { - id: uuid, - author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:37:07-06:00") - .unwrap() - .with_timezone(&chrono::Local), - done_time: None, - tags: Vec::::new(), - state: crate::issue::State::InProgress, - dependencies: None, - assignee: Some(String::from("beep boop")), - description: String::from("minimal"), - comments: Vec::::new(), - dir, - }); - - let uuid = String::from("3943fc5c173fdf41c0a22251593cd476"); - let mut dir = std::path::PathBuf::from(issues_dir); - dir.push(&uuid); - expected.add_issue( - crate::issue::Issue { - id: uuid, - author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:36:25-06:00") - .unwrap() - .with_timezone(&chrono::Local), - done_time: None, - tags: Vec::::from([ - String::from("TAG2"), - String::from("i-am-also-a-tag"), - String::from("tag1"), - ]), - state: crate::issue::State::New, - dependencies: None, - assignee: None, - 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"), - comments: Vec::::new(), - dir, - } - ); - assert_eq!(issues, expected); - } - - #[test] - fn read_issues_0001() { - let issues_dir = std::path::Path::new("test/0001/"); - let issues = Issues::new_from_dir(issues_dir).unwrap(); - - let mut expected = Issues::new(); - - let uuid = String::from("3fa5bfd93317ad25772680071d5ac325"); - let mut dir = std::path::PathBuf::from(issues_dir); - dir.push(&uuid); - expected.add_issue(crate::issue::Issue { - id: uuid, - author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:37:46-06:00") - .unwrap() - .with_timezone(&chrono::Local), - done_time: Some( - chrono::DateTime::parse_from_rfc3339("2025-07-15T15:15:15-06:00") - .unwrap() - .with_timezone(&chrono::Local), - ), - tags: Vec::::new(), - state: crate::issue::State::Done, - dependencies: None, - assignee: None, - description: String::from("oh yeah we got titles"), - comments: Vec::::new(), - dir, - }); - - let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe"); - 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 = Vec::::new(); - expected_comments.push( - crate::comment::Comment { - uuid: comment_uuid, - author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T10:08:38-06:00").unwrap().with_timezone(&chrono::Local), - description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe\n\nIt has multiple lines\n"), - dir: std::path::PathBuf::from(comment_dir), - } - ); - expected.add_issue( - crate::issue::Issue { - id: uuid, - author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T10:08:24-06:00") - .unwrap() - .with_timezone(&chrono::Local), - done_time: None, - tags: Vec::::new(), - state: crate::issue::State::WontDo, - dependencies: None, - assignee: None, - description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), - comments: expected_comments, - dir, - }, - ); - assert_eq!(issues, expected); - } - - #[test] - fn read_issues_0002() { - let issues_dir = std::path::Path::new("test/0002/"); - let issues = Issues::new_from_dir(issues_dir).unwrap(); - - let mut expected = Issues::new(); - - let uuid = String::from("3fa5bfd93317ad25772680071d5ac325"); - let mut dir = std::path::PathBuf::from(issues_dir); - dir.push(&uuid); - expected.add_issue(crate::issue::Issue { - id: uuid, - author: String::from("sigil-03 "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:38:40-06:00") - .unwrap() - .with_timezone(&chrono::Local), - done_time: None, - tags: Vec::::new(), - state: crate::issue::State::Done, - dependencies: None, - assignee: None, - description: String::from("oh yeah we got titles\n"), - comments: Vec::::new(), - dir, - }); - - let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe"); - let mut dir = std::path::PathBuf::from(issues_dir); - dir.push(&uuid); - expected.add_issue( - crate::issue::Issue { - id: uuid, - author: String::from("sigil-03 "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:39:20-06:00") - .unwrap() - .with_timezone(&chrono::Local), - done_time: None, - tags: Vec::::new(), - state: crate::issue::State::WontDo, - dependencies: None, - assignee: None, - description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), - comments: Vec::::new(), - dir, - }, - ); - - let uuid = String::from("a85f81fc5f14cb5d4851dd445dc9744c"); - let mut dir = std::path::PathBuf::from(issues_dir); - dir.push(&uuid); - expected.add_issue( - crate::issue::Issue { - id: uuid, - author: String::from("sigil-03 "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:39:02-06:00") - .unwrap() - .with_timezone(&chrono::Local), - done_time: None, - tags: Vec::::new(), - state: crate::issue::State::WontDo, - dependencies: Some(vec![ - crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac325"), - crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe"), - ]), - assignee: None, - description: String::from("issue with dependencies\n\na test has begun\nfor dependencies we seek\nintertwining life"), - comments: Vec::::new(), - dir, - }, - ); - assert_eq!(issues, expected); - } -} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index b6245b9..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,117 +0,0 @@ -use std::str::FromStr; - -pub mod comment; -pub mod database; -pub mod git; -pub mod issue; -pub mod issues; - -use crate::issue::State; - -#[derive(Debug, thiserror::Error)] -pub enum ParseFilterError { - #[error("Failed to parse filter")] - ParseError, - #[error(transparent)] - IssueParseError(#[from] crate::issue::IssueError), - #[error(transparent)] - ChronoParseError(#[from] chrono::format::ParseError), -} - -// 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<'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>, - pub start_done_time: Option>, - pub end_done_time: Option>, -} - -impl<'a> Filter<'a> { - pub fn new() -> Filter<'a> { - Self { - include_states: std::collections::HashSet::::from([ - State::InProgress, - State::Blocked, - State::Backlog, - 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(), - start_done_time: None, - end_done_time: None, - } - } - - pub fn parse(&mut self, filter_str: &'a str) -> Result<(), ParseFilterError> { - let tokens: Vec<&str> = filter_str.split("=").collect(); - if tokens.len() != 2 { - return Err(ParseFilterError::ParseError); - } - - match tokens[0] { - "state" => { - self.include_states.clear(); - for s in tokens[1].split(",") { - self.include_states - .insert(crate::issue::State::from_str(s)?); - } - } - - "assignee" => { - self.include_assignees.clear(); - for s in tokens[1].split(",") { - self.include_assignees.insert(s); - } - } - - "tag" => { - self.include_tags.clear(); - self.exclude_tags.clear(); - for s in tokens[1].split(",") { - if s.len() == 0 { - return Err(ParseFilterError::ParseError); - } - if s.chars().nth(0).unwrap() == '-' { - self.exclude_tags.insert(&s[1..]); - } else { - self.include_tags.insert(s); - } - } - } - - "done-time" => { - self.start_done_time = None; - self.end_done_time = None; - let times: Vec<&str> = tokens[1].split("..").collect(); - if times.len() > 2 { - return Err(ParseFilterError::ParseError); - } - if times[0].len() != 0 { - self.start_done_time = Some( - chrono::DateTime::parse_from_rfc3339(times[0])? - .with_timezone(&chrono::Local), - ); - } - if times[1].len() != 0 { - self.end_done_time = Some( - chrono::DateTime::parse_from_rfc3339(times[1])? - .with_timezone(&chrono::Local), - ); - } - } - - _ => { - println!("unknown filter string '{}'", filter_str); - return Err(ParseFilterError::ParseError); - } - } - - Ok(()) - } -} diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476/description b/test/0000/3943fc5c173fdf41c0a22251593cd476/description deleted file mode 100644 index e380829..0000000 --- a/test/0000/3943fc5c173fdf41c0a22251593cd476/description +++ /dev/null @@ -1,6 +0,0 @@ -this is the title of my issue - -This is the description of my issue. -It is multiple lines. -* Arbitrary contents -* But let's use markdown by convention diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476/tags b/test/0000/3943fc5c173fdf41c0a22251593cd476/tags deleted file mode 100644 index 04e82a6..0000000 --- a/test/0000/3943fc5c173fdf41c0a22251593cd476/tags +++ /dev/null @@ -1,3 +0,0 @@ -tag1 -TAG2 -i-am-also-a-tag diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c14/assignee b/test/0000/7792b063eef6d33e7da5dc1856750c14/assignee deleted file mode 100644 index fae06e3..0000000 --- a/test/0000/7792b063eef6d33e7da5dc1856750c14/assignee +++ /dev/null @@ -1 +0,0 @@ -beep boop diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c14/description b/test/0000/7792b063eef6d33e7da5dc1856750c14/description deleted file mode 100644 index 982085a..0000000 --- a/test/0000/7792b063eef6d33e7da5dc1856750c14/description +++ /dev/null @@ -1 +0,0 @@ -minimal \ No newline at end of file diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c14/state b/test/0000/7792b063eef6d33e7da5dc1856750c14/state deleted file mode 100644 index 0737713..0000000 --- a/test/0000/7792b063eef6d33e7da5dc1856750c14/state +++ /dev/null @@ -1 +0,0 @@ -inprogress diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac325/description b/test/0001/3fa5bfd93317ad25772680071d5ac325/description deleted file mode 100644 index c73d593..0000000 --- a/test/0001/3fa5bfd93317ad25772680071d5ac325/description +++ /dev/null @@ -1 +0,0 @@ -oh yeah we got titles \ No newline at end of file diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac325/done_time b/test/0001/3fa5bfd93317ad25772680071d5ac325/done_time deleted file mode 100644 index d455c4d..0000000 --- a/test/0001/3fa5bfd93317ad25772680071d5ac325/done_time +++ /dev/null @@ -1 +0,0 @@ -2025-07-15T15:15:15-06:00 diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac325/state b/test/0001/3fa5bfd93317ad25772680071d5ac325/state deleted file mode 100644 index 19f86f4..0000000 --- a/test/0001/3fa5bfd93317ad25772680071d5ac325/state +++ /dev/null @@ -1 +0,0 @@ -done diff --git a/test/0001/config.toml b/test/0001/config.toml deleted file mode 100644 index dfc15f2..0000000 --- a/test/0001/config.toml +++ /dev/null @@ -1 +0,0 @@ -states = [ "open", "closed" ] diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description deleted file mode 100644 index daa3d62..0000000 --- a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description +++ /dev/null @@ -1,3 +0,0 @@ -This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe - -It has multiple lines diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/description deleted file mode 100644 index a65ceb6..0000000 --- a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/description +++ /dev/null @@ -1,6 +0,0 @@ -issues out the wazoo - -Lots of words -that don't say much -because this is just -a test diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/state b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/state deleted file mode 100644 index 7f19192..0000000 --- a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/state +++ /dev/null @@ -1 +0,0 @@ -wontdo diff --git a/test/0002/3fa5bfd93317ad25772680071d5ac325/description b/test/0002/3fa5bfd93317ad25772680071d5ac325/description deleted file mode 100644 index 18a1926..0000000 --- a/test/0002/3fa5bfd93317ad25772680071d5ac325/description +++ /dev/null @@ -1 +0,0 @@ -oh yeah we got titles diff --git a/test/0002/3fa5bfd93317ad25772680071d5ac325/state b/test/0002/3fa5bfd93317ad25772680071d5ac325/state deleted file mode 100644 index 19f86f4..0000000 --- a/test/0002/3fa5bfd93317ad25772680071d5ac325/state +++ /dev/null @@ -1 +0,0 @@ -done diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/dependencies/3fa5bfd93317ad25772680071d5ac325 b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/dependencies/3fa5bfd93317ad25772680071d5ac325 deleted file mode 100644 index e69de29..0000000 diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/dependencies/dd79c8cfb8beeacd0460429944b4ecbe b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/dependencies/dd79c8cfb8beeacd0460429944b4ecbe deleted file mode 100644 index e69de29..0000000 diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/description b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/description deleted file mode 100644 index 42e2ce3..0000000 --- a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/description +++ /dev/null @@ -1,5 +0,0 @@ -issue with dependencies - -a test has begun -for dependencies we seek -intertwining life \ No newline at end of file diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/state b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/state deleted file mode 100644 index 7f19192..0000000 --- a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/state +++ /dev/null @@ -1 +0,0 @@ -wontdo diff --git a/test/0002/config.toml b/test/0002/config.toml deleted file mode 100644 index dfc15f2..0000000 --- a/test/0002/config.toml +++ /dev/null @@ -1 +0,0 @@ -states = [ "open", "closed" ] diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/description b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/description deleted file mode 100644 index a65ceb6..0000000 --- a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/description +++ /dev/null @@ -1,6 +0,0 @@ -issues out the wazoo - -Lots of words -that don't say much -because this is just -a test diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/state b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/state deleted file mode 100644 index 7f19192..0000000 --- a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/state +++ /dev/null @@ -1 +0,0 @@ -wontdo diff --git a/tools/README.md b/tools/README.md deleted file mode 100644 index dd3cb2f..0000000 --- a/tools/README.md +++ /dev/null @@ -1,4 +0,0 @@ -This directory contains small helper scripts and tools that are peripheral -or tangent to the main entomologist tool. - -We make no guarantees about functionality or correctness. diff --git a/tools/done-last-week b/tools/done-last-week deleted file mode 100755 index da9ec94..0000000 --- a/tools/done-last-week +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -START=$(date --iso-8601=seconds --date='last monday - 1 week') -END=$(date --iso-8601=seconds --date='last monday') - -#echo START=${START} -#echo END=${END} - -ent list \ - state=done \ - done-time="${START}..${END}" diff --git a/tools/set-done-time b/tools/set-done-time deleted file mode 100755 index 6a29fd9..0000000 --- a/tools/set-done-time +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash -# -# This script finds all issues with state=Done which do not have a -# `done_time`. -# -# It sets each issue's `done_time` to the most recent time that the -# `state` was updated from the git log. -# - -set -e - -for ISSUE_ID in $(ent list state=done done-time=9999-01-01T00:00:00-06:00.. | grep ' ' | cut -f 1 -d ' '); do - echo ${ISSUE_ID} - UTIME=$(PAGER='' git log -n1 --pretty=format:%at%n entomologist-data -- ${ISSUE_ID}/state) - echo ${UTIME} - DATETIME=$(date --rfc-3339=seconds --date="@${UTIME}") - echo ${DATETIME} - ent done-time ${ISSUE_ID} "${DATETIME}" -done diff --git a/tools/time-ent b/tools/time-ent deleted file mode 100755 index 366a02c..0000000 --- a/tools/time-ent +++ /dev/null @@ -1,43 +0,0 @@ -#!/bin/bash -# -# * Create a temporary ent issue database branch based on a specific -# commit in `entomologist-data`. -# -# * Perform some ent operations on this temporary branch and measure -# the runtime. -# -# * Clean up by deleteting the temporary branch. - -set -e -#set -x - - -# This is a commit in the `entomologist-data` branch that we're somewhat -# arbitrarily using here to time different `ent` operations. -TEST_COMMIT=a33f1165d77571d770f1a1021afe4c07360247f0 - -# This is the branch that we create from the above commit and test our -# `ent` operations on. We'll delete this branch when we're done with -# the tests. -TEST_BRANCH=$(mktemp --dry-run entomologist-data-XXXXXXXX) - - -function time_ent() { - echo timing: ent "$@" - time -p ent -b "${TEST_BRANCH}" "$@" - echo -} - - -git branch "${TEST_BRANCH}" "${TEST_COMMIT}" - -time_ent tag 7e2a3a59fb6b77403ff1035255367607 -time_ent tag 7e2a3a59fb6b77403ff1035255367607 new-tag - -time_ent assign 7e2a3a59fb6b77403ff1035255367607 -time_ent assign 7e2a3a59fb6b77403ff1035255367607 new-user - -time_ent done-time 7e2a3a59fb6b77403ff1035255367607 -time_ent done-time 7e2a3a59fb6b77403ff1035255367607 2025-04-01T01:23:45-06:00 - -git branch -D "${TEST_BRANCH}"