Compare commits

...

No commits in common. "main" and "2b186e01b2cc1f2c60f941e5e05418390e8fef91" have entirely different histories.

42 changed files with 15 additions and 2707 deletions

2
.gitignore vendored
View file

@ -1,2 +0,0 @@
/target
Cargo.lock

View file

@ -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

View file

@ -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?

View file

@ -0,0 +1 @@
implement `ent edit ${COMMENT}`

View file

@ -0,0 +1 @@
migrate the Todo list into entomologist

View file

@ -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"

View file

@ -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.

View file

@ -0,0 +1 @@
write a manpage

View file

@ -0,0 +1 @@
add user control over state transitions

View file

@ -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}"

View file

@ -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<String>,
/// Branch containing issues.
#[arg(short = 'b', long)]
issues_branch: Option<String>,
/// 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<String>,
},
/// Create a new issue.
New { description: Option<String> },
/// 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<State>,
},
/// Create a new comment on an issue.
Comment {
issue_id: String,
description: Option<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// Get or set the `done_time` of the Issue.
DoneTime {
issue_id: String,
done_time: Option<String>,
},
/// get or add a dependency to the issue
Depend {
issue_id: String,
dependency_id: Option<String>,
},
}
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(())
}

View file

@ -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<chrono::Local>,
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<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
);
}
}
}
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<String>,
) -> Result<crate::comment::Comment, CommentError> {
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 <seb@highlab.com>"),
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);
}
}

View file

@ -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<crate::git::Worktree>,
}
pub enum IssuesDatabaseAccess {
ReadOnly,
ReadWrite,
}
pub fn make_issues_database(
issues_database_source: &IssuesDatabaseSource,
access_type: IssuesDatabaseAccess,
) -> Result<IssuesDatabase, Error> {
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<crate::issues::Issues, Error> {
let issues_database =
make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadOnly)?;
Ok(crate::issues::Issues::new_from_dir(
&issues_database.dir,
)?)
}

View file

@ -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<Worktree, GitError> {
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<Worktree, GitError> {
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<bool, GitError> {
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<bool, GitError> {
// `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<chrono::DateTime<chrono::Local>, 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::<i64>()?;
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<String, GitError> {
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<chrono::Local>), 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::<i64>()?;
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);
}
}

View file

@ -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<chrono::Local>,
pub done_time: Option<chrono::DateTime<chrono::Local>>,
pub tags: Vec<String>,
pub state: State,
pub dependencies: Option<Vec<IssueHandle>>,
pub assignee: Option<String>,
pub description: String,
pub comments: Vec<crate::comment::Comment>,
/// 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<Self, Self::Err> {
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<Self, IssueError> {
let mut description: Option<String> = None;
let mut state = State::New; // default state, if not specified in the issue
let mut dependencies: Option<Vec<String>> = None;
let mut comments = Vec::<crate::comment::Comment>::new();
let mut assignee: Option<String> = None;
let mut tags = Vec::<String>::new();
let mut done_time: Option<chrono::DateTime<chrono::Local>> = 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<crate::comment::Comment>,
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<Option<Vec<IssueHandle>>, IssueError> {
let mut dependencies: Option<Vec<String>> = 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<String>,
) -> Result<crate::comment::Comment, IssueError> {
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<String>) -> Result<Self, IssueError> {
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::<String>::new(),
state: State::New,
dependencies: None,
assignee: None,
description: String::from(""), // FIXME: kind of bogus to use the empty string as None
comments: Vec::<crate::comment::Comment>::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<chrono::Local>,
) -> 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 <seb@highlab.com>"),
creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:36:25-06:00")
.unwrap()
.with_timezone(&chrono::Local),
done_time: None,
tags: Vec::<String>::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::<crate::comment::Comment>::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 <seb@highlab.com>"),
creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:37:07-06:00")
.unwrap()
.with_timezone(&chrono::Local),
done_time: None,
tags: Vec::<String>::new(),
state: State::InProgress,
dependencies: None,
assignee: Some(String::from("beep boop")),
description: String::from("minimal"),
comments: Vec::<crate::comment::Comment>::new(),
dir: std::path::PathBuf::from(issue_dir),
};
assert_eq!(issue, expected);
}
}

View file

@ -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<String, crate::issue::Issue>,
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<Self, ReadIssuesError> {
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 <seb@highlab.com>"),
creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:37:07-06:00")
.unwrap()
.with_timezone(&chrono::Local),
done_time: None,
tags: Vec::<String>::new(),
state: crate::issue::State::InProgress,
dependencies: None,
assignee: Some(String::from("beep boop")),
description: String::from("minimal"),
comments: Vec::<crate::comment::Comment>::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 <seb@highlab.com>"),
creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:36:25-06:00")
.unwrap()
.with_timezone(&chrono::Local),
done_time: None,
tags: Vec::<String>::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::<crate::comment::Comment>::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 <seb@highlab.com>"),
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::<String>::new(),
state: crate::issue::State::Done,
dependencies: None,
assignee: None,
description: String::from("oh yeah we got titles"),
comments: Vec::<crate::comment::Comment>::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::<crate::comment::Comment>::new();
expected_comments.push(
crate::comment::Comment {
uuid: comment_uuid,
author: String::from("Sebastian Kuzminsky <seb@highlab.com>"),
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 <seb@highlab.com>"),
creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T10:08:24-06:00")
.unwrap()
.with_timezone(&chrono::Local),
done_time: None,
tags: Vec::<String>::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 <sigil@glyphs.tech>"),
creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:38:40-06:00")
.unwrap()
.with_timezone(&chrono::Local),
done_time: None,
tags: Vec::<String>::new(),
state: crate::issue::State::Done,
dependencies: None,
assignee: None,
description: String::from("oh yeah we got titles\n"),
comments: Vec::<crate::comment::Comment>::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 <sigil@glyphs.tech>"),
creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:39:20-06:00")
.unwrap()
.with_timezone(&chrono::Local),
done_time: None,
tags: Vec::<String>::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::<crate::comment::Comment>::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 <sigil@glyphs.tech>"),
creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:39:02-06:00")
.unwrap()
.with_timezone(&chrono::Local),
done_time: None,
tags: Vec::<String>::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::<crate::comment::Comment>::new(),
dir,
},
);
assert_eq!(issues, expected);
}
}

View file

@ -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<crate::issue::State>,
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<chrono::DateTime<chrono::Local>>,
pub end_done_time: Option<chrono::DateTime<chrono::Local>>,
}
impl<'a> Filter<'a> {
pub fn new() -> Filter<'a> {
Self {
include_states: std::collections::HashSet::<crate::issue::State>::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(())
}
}

View file

@ -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

View file

@ -1,3 +0,0 @@
tag1
TAG2
i-am-also-a-tag

View file

@ -1 +0,0 @@
beep boop

View file

@ -1 +0,0 @@
minimal

View file

@ -1 +0,0 @@
inprogress

View file

@ -1 +0,0 @@
oh yeah we got titles

View file

@ -1 +0,0 @@
2025-07-15T15:15:15-06:00

View file

@ -1 +0,0 @@
done

View file

@ -1 +0,0 @@
states = [ "open", "closed" ]

View file

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

View file

@ -1,6 +0,0 @@
issues out the wazoo
Lots of words
that don't say much
because this is just
a test

View file

@ -1 +0,0 @@
wontdo

View file

@ -1 +0,0 @@
oh yeah we got titles

View file

@ -1 +0,0 @@
done

View file

@ -1,5 +0,0 @@
issue with dependencies
a test has begun
for dependencies we seek
intertwining life

View file

@ -1 +0,0 @@
wontdo

View file

@ -1 +0,0 @@
states = [ "open", "closed" ]

View file

@ -1,6 +0,0 @@
issues out the wazoo
Lots of words
that don't say much
because this is just
a test

View file

@ -1 +0,0 @@
wontdo

View file

@ -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.

View file

@ -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}"

View file

@ -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

View file

@ -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}"