From 63b2118ce7d2741dac5b11da6d85964fd6c98b13 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 3 Jul 2025 08:41:20 -0600 Subject: [PATCH 001/123] add a README --- README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ca1a4e --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +This is a distributed, collaborative bug tracker, backed by git. From b3a7ef3f14af781fbbbbd250f7a00738b3d2218d Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 3 Jul 2025 08:42:20 -0600 Subject: [PATCH 002/123] cargo init --lib --- .gitignore | 1 + Cargo.toml | 6 ++++++ src/lib.rs | 14 ++++++++++++++ 3 files changed, 21 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ba3bd7b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "entomologist" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b93cf3f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} From c3fc87da3de60479b2352aebdfe15720d121cece Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 3 Jul 2025 11:59:28 -0600 Subject: [PATCH 003/123] remove dead code from lib.rs --- src/lib.rs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b93cf3f..8b13789 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} From 07e9160cb5ed16de29ccc8844c3cd10755141fdb Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 3 Jul 2025 16:50:26 -0600 Subject: [PATCH 004/123] git ignore Cargo.lock --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ea8c4bf..96ef6c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +Cargo.lock From b9979f5e9ecee98ae16a3d571c535077678262f0 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 3 Jul 2025 12:14:26 -0600 Subject: [PATCH 005/123] start adding Issue struct This abstracts a single issue. --- Cargo.toml | 1 + src/issue.rs | 109 ++++++++++++++++++ src/lib.rs | 2 +- .../description | 4 + .../title | 1 + .../state | 1 + .../title | 1 + 7 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/issue.rs create mode 100644 test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description create mode 100644 test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/title create mode 100644 test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/state create mode 100644 test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/title diff --git a/Cargo.toml b/Cargo.toml index ba3bd7b..905a9b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,3 +4,4 @@ version = "0.1.0" edition = "2024" [dependencies] +thiserror = "2.0.11" diff --git a/src/issue.rs b/src/issue.rs new file mode 100644 index 0000000..6dde005 --- /dev/null +++ b/src/issue.rs @@ -0,0 +1,109 @@ +use std::str::FromStr; + +#[derive(Debug, PartialEq, serde::Deserialize)] +/// These are the states an issue can be in. +pub enum State { + New, + Backlog, + InProgress, + Done, + WontDo, +} + +#[derive(Debug, PartialEq)] +pub struct Issue { + pub title: String, + pub description: Option, + pub state: State, +} + +#[derive(Debug, thiserror::Error)] +pub enum ReadIssueError { + #[error(transparent)] + StdIoError(#[from] std::io::Error), + #[error("Failed to parse issue")] + IssueParseError, +} + +impl FromStr for State { + type Err = ReadIssueError; + 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 == "inprogress" { + Ok(State::InProgress) + } else if s == "done" { + Ok(State::Done) + } else if s == "wontdo" { + Ok(State::WontDo) + } else { + Err(ReadIssueError::IssueParseError) + } + } +} + +impl Issue { + pub fn new_from_dir(dir: &std::path::Path) -> Result { + let mut title: Option = None; + let mut description: Option = None; + let mut state = State::New; // default state, if not specified in the issue + + for direntry in dir.read_dir()? { + if let Ok(direntry) = direntry { + let file_name = direntry.file_name(); + if file_name == "title" { + title = Some(std::fs::read_to_string(direntry.path())?.trim().into()); + } else 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 { + println!("ignoring unknown file in issue directory: {:?}", file_name); + } + } + } + + if title == None { + return Err(ReadIssueError::IssueParseError); + } + + Ok(Self { + title: title.unwrap(), + description: description, + state: state, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_issue_0() { + let issue_dir = std::path::Path::new("test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/"); + let issue = Issue::new_from_dir(issue_dir).unwrap(); + let expected = Issue { + title: String::from("this is the title of my issue"), + description: Some(String::from("This is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n")), + state: State::New, + }; + assert_eq!(issue, expected); + } + + #[test] + fn read_issue_1() { + let issue_dir = std::path::Path::new("test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/"); + let issue = Issue::new_from_dir(issue_dir).unwrap(); + let expected = Issue { + title: String::from("minimal"), + description: None, + state: State::InProgress, + }; + assert_eq!(issue, expected); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8b13789..d93d369 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1 @@ - +pub mod issue; diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description new file mode 100644 index 0000000..3db0fcf --- /dev/null +++ b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description @@ -0,0 +1,4 @@ +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/3943fc5c173fdf41c0a22251593cd476d96e6c9f/title b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/title new file mode 100644 index 0000000..c9c2379 --- /dev/null +++ b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/title @@ -0,0 +1 @@ +this is the title of my issue diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/state b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/state new file mode 100644 index 0000000..0737713 --- /dev/null +++ b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/state @@ -0,0 +1 @@ +inprogress diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/title b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/title new file mode 100644 index 0000000..dd1a932 --- /dev/null +++ b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/title @@ -0,0 +1 @@ +minimal From 16c6288cee4fcca178ef129952865e171e25a88a Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 3 Jul 2025 11:59:44 -0600 Subject: [PATCH 006/123] start adding Issues struct This holds everything there is to know about everything, for now that's all issues but in the future there might be more? --- Cargo.toml | 2 + src/issues.rs | 125 ++++++++++++++++++ src/lib.rs | 1 + .../state | 1 + .../title | 1 + test/0001/config.toml | 1 + .../description | 4 + .../state | 1 + .../title | 1 + 9 files changed, 137 insertions(+) create mode 100644 src/issues.rs create mode 100644 test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/state create mode 100644 test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/title create mode 100644 test/0001/config.toml create mode 100644 test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description create mode 100644 test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state create mode 100644 test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title diff --git a/Cargo.toml b/Cargo.toml index 905a9b5..2230bb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,4 +4,6 @@ version = "0.1.0" edition = "2024" [dependencies] +serde = { version = "1.0.217", features = ["derive"] } thiserror = "2.0.11" +toml = "0.8.19" diff --git a/src/issues.rs b/src/issues.rs new file mode 100644 index 0000000..b5c1c87 --- /dev/null +++ b/src/issues.rs @@ -0,0 +1,125 @@ +// 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("Failed to parse issue")] + IssueParseError(#[from] crate::issue::ReadIssueError), + #[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, uuid: String, issue: crate::issue::Issue) { + self.issues.insert(uuid, issue); + } + + 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() { + let uuid = match direntry.file_name().into_string() { + Ok(uuid) => uuid, + Err(orig_string) => { + return Err(ReadIssuesError::FilenameError(orig_string)) + } + }; + let issue = crate::issue::Issue::new_from_dir(direntry.path().as_path())?; + issues.add_issue(uuid, issue); + } else if direntry.file_name() == "config.toml" { + issues.parse_config(direntry.path().as_path())?; + } else { + println!( + "ignoring unknown file in issues directory: {:?}", + direntry.file_name() + ); + } + } + } + return Ok(issues); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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(); + expected.add_issue( + String::from("7792b063eef6d33e7da5dc1856750c149ba678c6"), + crate::issue::Issue { + title: String::from("minimal"), + description: None, + state: crate::issue::State::InProgress, + }, + ); + expected.add_issue( + String::from("3943fc5c173fdf41c0a22251593cd476d96e6c9f"), + crate::issue::Issue { + title: String::from("this is the title of my issue"), + description: Some(String::from("This is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n")), + state: crate::issue::State::New, + } + ); + 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(); + expected.add_issue( + String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), + crate::issue::Issue { + title: String::from("oh yeah we got titles"), + description: None, + state: crate::issue::State::Done, + }, + ); + expected.add_issue( + String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), + crate::issue::Issue { + title: String::from("issues out the wazoo"), + description: Some(String::from( + "Lots of words\nthat don't say much\nbecause this is just\na test\n", + )), + state: crate::issue::State::WontDo, + }, + ); + assert_eq!(issues, expected); + } +} diff --git a/src/lib.rs b/src/lib.rs index d93d369..713acd1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1,2 @@ pub mod issue; +pub mod issues; diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/state b/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/state new file mode 100644 index 0000000..19f86f4 --- /dev/null +++ b/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/state @@ -0,0 +1 @@ +done diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/title b/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/title new file mode 100644 index 0000000..18a1926 --- /dev/null +++ b/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/title @@ -0,0 +1 @@ +oh yeah we got titles diff --git a/test/0001/config.toml b/test/0001/config.toml new file mode 100644 index 0000000..dfc15f2 --- /dev/null +++ b/test/0001/config.toml @@ -0,0 +1 @@ +states = [ "open", "closed" ] diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description new file mode 100644 index 0000000..010156b --- /dev/null +++ b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description @@ -0,0 +1,4 @@ +Lots of words +that don't say much +because this is just +a test diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state new file mode 100644 index 0000000..7f19192 --- /dev/null +++ b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state @@ -0,0 +1 @@ +wontdo diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title new file mode 100644 index 0000000..ab5b4a9 --- /dev/null +++ b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title @@ -0,0 +1 @@ +issues out the wazoo From d94c991eaac39dfd25fa3dd1d6317f352705693d Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Sat, 5 Jul 2025 13:55:49 -0600 Subject: [PATCH 007/123] add dependency tracking to issue type --- src/issue.rs | 18 +++++++- src/issues.rs | 45 +++++++++++++++++++ .../state | 1 + .../title | 1 + .../dependencies | 2 + .../description | 3 ++ .../state | 1 + .../title | 1 + test/0002/config.toml | 1 + .../description | 4 ++ .../state | 1 + .../title | 1 + 12 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/state create mode 100644 test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/title create mode 100644 test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies create mode 100644 test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description create mode 100644 test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/state create mode 100644 test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/title create mode 100644 test/0002/config.toml create mode 100644 test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description create mode 100644 test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state create mode 100644 test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title diff --git a/src/issue.rs b/src/issue.rs index 6dde005..7171380 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -5,16 +5,20 @@ use std::str::FromStr; pub enum State { New, Backlog, + Blocked, InProgress, Done, WontDo, } +pub type IssueHandle = String; + #[derive(Debug, PartialEq)] pub struct Issue { pub title: String, pub description: Option, pub state: State, + pub dependencies: Option>, } #[derive(Debug, thiserror::Error)] @@ -33,6 +37,8 @@ impl FromStr for State { 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" { @@ -50,6 +56,7 @@ impl Issue { let mut title: Option = None; let mut description: Option = None; let mut state = State::New; // default state, if not specified in the issue + let mut dependencies: Option> = None; for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { @@ -60,7 +67,13 @@ impl Issue { 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())?; + state = State::from_str(state_string.trim())?; + } else if file_name == "dependencies" { + let dep_strings = std::fs::read_to_string(direntry.path())?; + let deps: Vec = dep_strings.lines().map(|dep|{IssueHandle::from(dep)}).collect(); + if deps.len() > 0 { + dependencies = Some(deps); + } } else { println!("ignoring unknown file in issue directory: {:?}", file_name); } @@ -75,6 +88,7 @@ impl Issue { title: title.unwrap(), description: description, state: state, + dependencies, }) } } @@ -91,6 +105,7 @@ mod tests { title: String::from("this is the title of my issue"), description: Some(String::from("This is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n")), state: State::New, + dependencies: None, }; assert_eq!(issue, expected); } @@ -103,6 +118,7 @@ mod tests { title: String::from("minimal"), description: None, state: State::InProgress, + dependencies: None, }; assert_eq!(issue, expected); } diff --git a/src/issues.rs b/src/issues.rs index b5c1c87..35116e7 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -83,6 +83,7 @@ mod tests { title: String::from("minimal"), description: None, state: crate::issue::State::InProgress, + dependencies: None, }, ); expected.add_issue( @@ -91,6 +92,7 @@ mod tests { title: String::from("this is the title of my issue"), description: Some(String::from("This is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n")), state: crate::issue::State::New, + dependencies: None, } ); assert_eq!(issues, expected); @@ -108,6 +110,7 @@ mod tests { title: String::from("oh yeah we got titles"), description: None, state: crate::issue::State::Done, + dependencies: None, }, ); expected.add_issue( @@ -118,8 +121,50 @@ mod tests { "Lots of words\nthat don't say much\nbecause this is just\na test\n", )), state: crate::issue::State::WontDo, + dependencies: None, + }, + ); + 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(); + expected.add_issue( + String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), + crate::issue::Issue { + title: String::from("oh yeah we got titles"), + description: None, + state: crate::issue::State::Done, + dependencies: None, + }, + ); + expected.add_issue( + String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), + crate::issue::Issue { + title: String::from("issues out the wazoo"), + description: Some(String::from( + "Lots of words\nthat don't say much\nbecause this is just\na test\n", + )), + state: crate::issue::State::WontDo, + dependencies: None, + }, + ); + expected.add_issue( + String::from("a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7"), + crate::issue::Issue { + title: String::from("issue with dependencies"), + description: Some(String::from( + "a test has begun\nfor dependencies we seek\nintertwining life", + )), + state: crate::issue::State::WontDo, + dependencies: Some(vec![crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561")]), }, ); assert_eq!(issues, expected); } } + diff --git a/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/state b/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/state new file mode 100644 index 0000000..19f86f4 --- /dev/null +++ b/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/state @@ -0,0 +1 @@ +done diff --git a/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/title b/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/title new file mode 100644 index 0000000..18a1926 --- /dev/null +++ b/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/title @@ -0,0 +1 @@ +oh yeah we got titles diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies new file mode 100644 index 0000000..71e4ee3 --- /dev/null +++ b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies @@ -0,0 +1,2 @@ +3fa5bfd93317ad25772680071d5ac3259cd2384f +dd79c8cfb8beeacd0460429944b4ecbe95a31561 \ No newline at end of file diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description new file mode 100644 index 0000000..049c15f --- /dev/null +++ b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description @@ -0,0 +1,3 @@ +a test has begun +for dependencies we seek +intertwining life \ No newline at end of file diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/state b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/state new file mode 100644 index 0000000..7f19192 --- /dev/null +++ b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/state @@ -0,0 +1 @@ +wontdo diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/title b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/title new file mode 100644 index 0000000..7c150e7 --- /dev/null +++ b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/title @@ -0,0 +1 @@ +issue with dependencies diff --git a/test/0002/config.toml b/test/0002/config.toml new file mode 100644 index 0000000..dfc15f2 --- /dev/null +++ b/test/0002/config.toml @@ -0,0 +1 @@ +states = [ "open", "closed" ] diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description new file mode 100644 index 0000000..010156b --- /dev/null +++ b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description @@ -0,0 +1,4 @@ +Lots of words +that don't say much +because this is just +a test diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state new file mode 100644 index 0000000..7f19192 --- /dev/null +++ b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state @@ -0,0 +1 @@ +wontdo diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title new file mode 100644 index 0000000..ab5b4a9 --- /dev/null +++ b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title @@ -0,0 +1 @@ +issues out the wazoo From e8910b906a7f05eb2a22c6752994161eb181aa63 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Fri, 4 Jul 2025 13:07:10 -0600 Subject: [PATCH 008/123] add git support This mostly provides an abstraction for "ephemeral worktrees", which is a branch checked out in a worktree, to be read and maybe modified, and the worktree is deleted/pruned when we're done with it. There are also some helper functions for doing git things, the most important one creates an orphaned branch. The intent is to keep all the issues in a git branch. When we want to do anything with issues (list them, add new issues, modify an issue, etc) we check the issues branch out into an ephemeral worktree, modify the branch, and delete the worktree. --- Cargo.toml | 2 + src/git.rs | 200 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 3 files changed, 203 insertions(+) create mode 100644 src/git.rs diff --git a/Cargo.toml b/Cargo.toml index 2230bb4..8c9d262 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] +rand = "0.9.1" serde = { version = "1.0.217", features = ["derive"] } +tempfile = "3.20.0" thiserror = "2.0.11" toml = "0.8.19" diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..2b03b5a --- /dev/null +++ b/src/git.rs @@ -0,0 +1,200 @@ +use std::io::Write; + +#[derive(Debug, thiserror::Error)] +pub enum GitError { + #[error(transparent)] + StdIoError(#[from] std::io::Error), + #[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", &self.path.path().to_string_lossy()]) + .output(); + } +} + +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: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + 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: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + 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: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + 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: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + 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 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: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + 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: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + 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: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + 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: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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!("{:0x}", 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!("{:0x}", rnd)); + let r = git_branch_exists(&branch).unwrap(); + assert_eq!(r, false); + } +} diff --git a/src/lib.rs b/src/lib.rs index 713acd1..cca1c90 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,3 @@ +pub mod git; pub mod issue; pub mod issues; From 26c98591b52be4f32f5b767de81f1c72d5a94375 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 3 Jul 2025 12:14:33 -0600 Subject: [PATCH 009/123] start adding `ent` binary --- Cargo.toml | 2 ++ src/bin/ent/main.rs | 77 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/bin/ent/main.rs diff --git a/Cargo.toml b/Cargo.toml index 8c9d262..1f7941d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] +anyhow = "1.0.95" +clap = { version = "4.5.26", features = ["derive"] } rand = "0.9.1" serde = { version = "1.0.217", features = ["derive"] } tempfile = "3.20.0" diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs new file mode 100644 index 0000000..86dfa95 --- /dev/null +++ b/src/bin/ent/main.rs @@ -0,0 +1,77 @@ +use clap::Parser; + +#[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, + + /// Create a new issue. + New { + title: Option, + description: Option, + }, +} + +fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { + match &args.command { + Commands::List => { + let issues = + entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + for (uuid, issue) in issues.issues.iter() { + println!("{} {} ({:?})", uuid, issue.title, issue.state); + } + } + Commands::New { title, description } => { + println!( + "should make a new issue, title={:?}, description={:?}", + title, description + ); + } + } + + Ok(()) +} + +fn main() -> anyhow::Result<()> { + let args: Args = Args::parse(); + // println!("{:?}", args); + + if let (Some(_), Some(_)) = (&args.issues_dir, &args.issues_branch) { + return Err(anyhow::anyhow!( + "don't specify both `--issues-dir` and `--issues-branch`" + )); + } + + if let Some(dir) = &args.issues_dir { + let dir = std::path::Path::new(dir); + handle_command(&args, dir)?; + } else { + let branch = match &args.issues_branch { + Some(branch) => branch, + None => "entomologist-data", + }; + if !entomologist::git::git_branch_exists(branch)? { + entomologist::git::create_orphan_branch(branch)?; + } + let worktree = entomologist::git::Worktree::new(branch)?; + handle_command(&args, worktree.path())?; + } + + Ok(()) +} From 64022b16fa1ad1ec1569a76739c9d92944cc9b4a Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Fri, 4 Jul 2025 00:51:12 -0600 Subject: [PATCH 010/123] add a Todo file, ironically --- Todo.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 Todo.md diff --git a/Todo.md b/Todo.md new file mode 100644 index 0000000..56728ad --- /dev/null +++ b/Todo.md @@ -0,0 +1,30 @@ +# To do + +* migrate this todo list into entomologist + +* teach it to work with a git branch + - unpack the branch to a directory with `git worktree ${TMPDIR} ${BRANCH}` + - operate on the issues in that worktree + - git commit the result back to ${BRANCH} + - delete and prune the worktree + +* implement `ent new` + +* implement user control over state transitions + +* 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? + +* implement `ent edit ${ISSUE} [-t ${TITLE}] [-d ${DESCRIPTION}]` + - or would it be better to put the title and description together into a new `message`, like git commits? + +* implement `ent edit ${COMMENT}` + +* 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 + +* write a manpage From 559e70077e36b1bedac9c7ee881298da02ebb456 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 5 Jul 2025 22:40:28 -0600 Subject: [PATCH 011/123] "title" is just the first line of "description" now --- src/bin/ent/main.rs | 2 +- src/issue.rs | 33 ++++++++++--------- src/issues.rs | 33 +++++++------------ .../description | 2 ++ .../title | 1 - .../description | 1 + .../title | 1 - .../description | 1 + .../description | 2 ++ .../title | 1 - .../description} | 0 .../title | 1 - .../description | 2 ++ .../title | 1 - .../description | 2 ++ .../title | 1 - 16 files changed, 40 insertions(+), 44 deletions(-) delete mode 100644 test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/title create mode 100644 test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/description delete mode 100644 test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/title create mode 100644 test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/description delete mode 100644 test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title rename test/{0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/title => 0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/description} (100%) delete mode 100644 test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/title delete mode 100644 test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/title delete mode 100644 test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 86dfa95..65e36dc 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -34,7 +34,7 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( let issues = entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; for (uuid, issue) in issues.issues.iter() { - println!("{} {} ({:?})", uuid, issue.title, issue.state); + println!("{} {} ({:?})", uuid, issue.title(), issue.state); } } Commands::New { title, description } => { diff --git a/src/issue.rs b/src/issue.rs index 7171380..939af47 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -15,8 +15,7 @@ pub type IssueHandle = String; #[derive(Debug, PartialEq)] pub struct Issue { - pub title: String, - pub description: Option, + pub description: String, pub state: State, pub dependencies: Option>, } @@ -53,7 +52,6 @@ impl FromStr for State { impl Issue { pub fn new_from_dir(dir: &std::path::Path) -> Result { - let mut title: Option = None; let mut description: Option = None; let mut state = State::New; // default state, if not specified in the issue let mut dependencies: Option> = None; @@ -61,16 +59,17 @@ impl Issue { for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { let file_name = direntry.file_name(); - if file_name == "title" { - title = Some(std::fs::read_to_string(direntry.path())?.trim().into()); - } else if file_name == "description" { + 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())?; + state = State::from_str(state_string.trim())?; } else if file_name == "dependencies" { let dep_strings = std::fs::read_to_string(direntry.path())?; - let deps: Vec = dep_strings.lines().map(|dep|{IssueHandle::from(dep)}).collect(); + let deps: Vec = dep_strings + .lines() + .map(|dep| IssueHandle::from(dep)) + .collect(); if deps.len() > 0 { dependencies = Some(deps); } @@ -80,17 +79,23 @@ impl Issue { } } - if title == None { + if description == None { return Err(ReadIssueError::IssueParseError); } Ok(Self { - title: title.unwrap(), - description: description, + description: description.unwrap(), state: state, dependencies, }) } + + 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(), + } + } } #[cfg(test)] @@ -102,8 +107,7 @@ mod tests { let issue_dir = std::path::Path::new("test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/"); let issue = Issue::new_from_dir(issue_dir).unwrap(); let expected = Issue { - title: String::from("this is the title of my issue"), - description: Some(String::from("This is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n")), + description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), state: State::New, dependencies: None, }; @@ -115,8 +119,7 @@ mod tests { let issue_dir = std::path::Path::new("test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/"); let issue = Issue::new_from_dir(issue_dir).unwrap(); let expected = Issue { - title: String::from("minimal"), - description: None, + description: String::from("minimal"), state: State::InProgress, dependencies: None, }; diff --git a/src/issues.rs b/src/issues.rs index 35116e7..9422ded 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -80,8 +80,7 @@ mod tests { expected.add_issue( String::from("7792b063eef6d33e7da5dc1856750c149ba678c6"), crate::issue::Issue { - title: String::from("minimal"), - description: None, + description: String::from("minimal"), state: crate::issue::State::InProgress, dependencies: None, }, @@ -89,8 +88,7 @@ mod tests { expected.add_issue( String::from("3943fc5c173fdf41c0a22251593cd476d96e6c9f"), crate::issue::Issue { - title: String::from("this is the title of my issue"), - description: Some(String::from("This is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n")), + description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), state: crate::issue::State::New, dependencies: None, } @@ -107,8 +105,7 @@ mod tests { expected.add_issue( String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::Issue { - title: String::from("oh yeah we got titles"), - description: None, + description: String::from("oh yeah we got titles"), state: crate::issue::State::Done, dependencies: None, }, @@ -116,10 +113,7 @@ mod tests { expected.add_issue( String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), crate::issue::Issue { - title: String::from("issues out the wazoo"), - description: Some(String::from( - "Lots of words\nthat don't say much\nbecause this is just\na test\n", - )), + description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), state: crate::issue::State::WontDo, dependencies: None, }, @@ -136,8 +130,7 @@ mod tests { expected.add_issue( String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::Issue { - title: String::from("oh yeah we got titles"), - description: None, + description: String::from("oh yeah we got titles\n"), state: crate::issue::State::Done, dependencies: None, }, @@ -145,10 +138,7 @@ mod tests { expected.add_issue( String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), crate::issue::Issue { - title: String::from("issues out the wazoo"), - description: Some(String::from( - "Lots of words\nthat don't say much\nbecause this is just\na test\n", - )), + description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), state: crate::issue::State::WontDo, dependencies: None, }, @@ -156,15 +146,14 @@ mod tests { expected.add_issue( String::from("a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7"), crate::issue::Issue { - title: String::from("issue with dependencies"), - description: Some(String::from( - "a test has begun\nfor dependencies we seek\nintertwining life", - )), + description: String::from("issue with dependencies\n\na test has begun\nfor dependencies we seek\nintertwining life"), state: crate::issue::State::WontDo, - dependencies: Some(vec![crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561")]), + dependencies: Some(vec![ + crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), + crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), + ]), }, ); assert_eq!(issues, expected); } } - diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description index 3db0fcf..e380829 100644 --- a/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description +++ b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description @@ -1,3 +1,5 @@ +this is the title of my issue + This is the description of my issue. It is multiple lines. * Arbitrary contents diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/title b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/title deleted file mode 100644 index c9c2379..0000000 --- a/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/title +++ /dev/null @@ -1 +0,0 @@ -this is the title of my issue diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/description b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/description new file mode 100644 index 0000000..982085a --- /dev/null +++ b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/description @@ -0,0 +1 @@ +minimal \ No newline at end of file diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/title b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/title deleted file mode 100644 index dd1a932..0000000 --- a/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/title +++ /dev/null @@ -1 +0,0 @@ -minimal diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/description b/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/description new file mode 100644 index 0000000..c73d593 --- /dev/null +++ b/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/description @@ -0,0 +1 @@ +oh yeah we got titles \ No newline at end of file diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description index 010156b..a65ceb6 100644 --- a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description +++ b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description @@ -1,3 +1,5 @@ +issues out the wazoo + Lots of words that don't say much because this is just diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title deleted file mode 100644 index ab5b4a9..0000000 --- a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title +++ /dev/null @@ -1 +0,0 @@ -issues out the wazoo diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/title b/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/description similarity index 100% rename from test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/title rename to test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/description diff --git a/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/title b/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/title deleted file mode 100644 index 18a1926..0000000 --- a/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/title +++ /dev/null @@ -1 +0,0 @@ -oh yeah we got titles diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description index 049c15f..42e2ce3 100644 --- a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description +++ b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description @@ -1,3 +1,5 @@ +issue with dependencies + a test has begun for dependencies we seek intertwining life \ No newline at end of file diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/title b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/title deleted file mode 100644 index 7c150e7..0000000 --- a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/title +++ /dev/null @@ -1 +0,0 @@ -issue with dependencies diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description index 010156b..a65ceb6 100644 --- a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description +++ b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description @@ -1,3 +1,5 @@ +issues out the wazoo + Lots of words that don't say much because this is just diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title deleted file mode 100644 index ab5b4a9..0000000 --- a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/title +++ /dev/null @@ -1 +0,0 @@ -issues out the wazoo From 1f4456fcaf4723810de70421bb52368cb2f079d4 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 5 Jul 2025 23:34:24 -0600 Subject: [PATCH 012/123] teach Issue to know what dir it lives in The Issue struct is a cache of files on disk. There is never an Issue without a directory to live in. This commit adds a field to Issue to track what that directory is, so that we can update those filew when we change the Issue, and commit the changes to git. --- src/issue.rs | 7 +++++++ src/issues.rs | 49 ++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/issue.rs b/src/issue.rs index 939af47..0f3dfd2 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -18,6 +18,10 @@ pub struct Issue { pub description: String, pub state: State, pub dependencies: Option>, + + /// 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)] @@ -87,6 +91,7 @@ impl Issue { description: description.unwrap(), state: state, dependencies, + dir: std::path::PathBuf::from(dir), }) } @@ -110,6 +115,7 @@ mod tests { description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), state: State::New, dependencies: None, + dir: std::path::PathBuf::from(issue_dir), }; assert_eq!(issue, expected); } @@ -122,6 +128,7 @@ mod tests { description: String::from("minimal"), state: State::InProgress, dependencies: None, + dir: std::path::PathBuf::from(issue_dir), }; assert_eq!(issue, expected); } diff --git a/src/issues.rs b/src/issues.rs index 9422ded..0bfda84 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -77,20 +77,30 @@ mod tests { let issues = Issues::new_from_dir(issues_dir).unwrap(); let mut expected = Issues::new(); + + let uuid = String::from("7792b063eef6d33e7da5dc1856750c149ba678c6"); + let mut dir = std::path::PathBuf::from(issues_dir); + dir.push(&uuid); expected.add_issue( - String::from("7792b063eef6d33e7da5dc1856750c149ba678c6"), + uuid, crate::issue::Issue { description: String::from("minimal"), state: crate::issue::State::InProgress, dependencies: None, + dir, }, ); + + let uuid = String::from("3943fc5c173fdf41c0a22251593cd476d96e6c9f"); + let mut dir = std::path::PathBuf::from(issues_dir); + dir.push(&uuid); expected.add_issue( - String::from("3943fc5c173fdf41c0a22251593cd476d96e6c9f"), + uuid, crate::issue::Issue { description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), state: crate::issue::State::New, dependencies: None, + dir, } ); assert_eq!(issues, expected); @@ -102,20 +112,30 @@ mod tests { let issues = Issues::new_from_dir(issues_dir).unwrap(); let mut expected = Issues::new(); + + let uuid = String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"); + let mut dir = std::path::PathBuf::from(issues_dir); + dir.push(&uuid); expected.add_issue( - String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), + uuid, crate::issue::Issue { description: String::from("oh yeah we got titles"), state: crate::issue::State::Done, dependencies: None, + dir, }, ); + + let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"); + let mut dir = std::path::PathBuf::from(issues_dir); + dir.push(&uuid); expected.add_issue( - String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), + uuid, crate::issue::Issue { description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), state: crate::issue::State::WontDo, dependencies: None, + dir, }, ); assert_eq!(issues, expected); @@ -127,24 +147,38 @@ mod tests { let issues = Issues::new_from_dir(issues_dir).unwrap(); let mut expected = Issues::new(); + + let uuid = String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"); + let mut dir = std::path::PathBuf::from(issues_dir); + dir.push(&uuid); expected.add_issue( - String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), + uuid, crate::issue::Issue { description: String::from("oh yeah we got titles\n"), state: crate::issue::State::Done, dependencies: None, + dir, }, ); + + let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"); + let mut dir = std::path::PathBuf::from(issues_dir); + dir.push(&uuid); expected.add_issue( - String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), + uuid, crate::issue::Issue { description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), state: crate::issue::State::WontDo, dependencies: None, + dir, }, ); + + let uuid = String::from("a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7"); + let mut dir = std::path::PathBuf::from(issues_dir); + dir.push(&uuid); expected.add_issue( - String::from("a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7"), + uuid, crate::issue::Issue { description: String::from("issue with dependencies\n\na test has begun\nfor dependencies we seek\nintertwining life"), state: crate::issue::State::WontDo, @@ -152,6 +186,7 @@ mod tests { crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), ]), + dir, }, ); assert_eq!(issues, expected); From 5b1c7a52b913450bc455385b9f905eb3ec1b2493 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 5 Jul 2025 23:51:54 -0600 Subject: [PATCH 013/123] git: add git_commit_file() --- src/git.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/git.rs b/src/git.rs index 2b03b5a..bb17763 100644 --- a/src/git.rs +++ b/src/git.rs @@ -89,6 +89,41 @@ pub fn git_branch_exists(branch: &str) -> Result { return Ok(result.status.success()); } +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().unwrap().to_string_lossy()]) + .current_dir(&git_dir) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + + let result = std::process::Command::new("git") + .args([ + "commit", + "-m", + &format!( + "update '{}' in issue {}", + file.file_name().unwrap().to_string_lossy(), + git_dir.file_name().unwrap().to_string_lossy() + ), + ]) + .current_dir(&git_dir) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + + Ok(()) +} + pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> { { let tmp_worktree = tempfile::tempdir().unwrap(); From 5e482edb5c5192efc447817f13172a08667f0fbe Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 6 Jul 2025 00:11:23 -0600 Subject: [PATCH 014/123] rename ReadIssueError to just IssueError Error handling is pretty broken in this project :-( --- src/issue.rs | 10 +++++----- src/issues.rs | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/issue.rs b/src/issue.rs index 0f3dfd2..753454b 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -25,7 +25,7 @@ pub struct Issue { } #[derive(Debug, thiserror::Error)] -pub enum ReadIssueError { +pub enum IssueError { #[error(transparent)] StdIoError(#[from] std::io::Error), #[error("Failed to parse issue")] @@ -33,7 +33,7 @@ pub enum ReadIssueError { } impl FromStr for State { - type Err = ReadIssueError; + type Err = IssueError; fn from_str(s: &str) -> Result { let s = s.to_lowercase(); if s == "new" { @@ -49,13 +49,13 @@ impl FromStr for State { } else if s == "wontdo" { Ok(State::WontDo) } else { - Err(ReadIssueError::IssueParseError) + Err(IssueError::IssueParseError) } } } impl Issue { - pub fn new_from_dir(dir: &std::path::Path) -> Result { + 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; @@ -84,7 +84,7 @@ impl Issue { } if description == None { - return Err(ReadIssueError::IssueParseError); + return Err(IssueError::IssueParseError); } Ok(Self { diff --git a/src/issues.rs b/src/issues.rs index 0bfda84..bb548e4 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -12,8 +12,8 @@ pub struct Issues { pub enum ReadIssuesError { #[error(transparent)] StdIoError(#[from] std::io::Error), - #[error("Failed to parse issue")] - IssueParseError(#[from] crate::issue::ReadIssueError), + #[error(transparent)] + IssueError(#[from] crate::issue::IssueError), #[error("cannot handle filename")] FilenameError(std::ffi::OsString), #[error(transparent)] From 09373cda56ecc1b00478a28a55a1cb7e8a76d341 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 6 Jul 2025 00:12:22 -0600 Subject: [PATCH 015/123] add `ent new` --- src/bin/ent/main.rs | 21 ++++++++++-------- src/issue.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 65e36dc..e0dca3c 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -22,10 +22,7 @@ enum Commands { List, /// Create a new issue. - New { - title: Option, - description: Option, - }, + New { description: Option }, } fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { @@ -37,11 +34,17 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( println!("{} {} ({:?})", uuid, issue.title(), issue.state); } } - Commands::New { title, description } => { - println!( - "should make a new issue, title={:?}, description={:?}", - title, description - ); + Commands::New { + description: Some(description), + } => { + let mut issue = entomologist::issue::Issue::new(issues_dir)?; + issue.set_description(description)?; + println!("created new issue '{}'", issue.title()); + } + Commands::New { description: None } => { + let mut issue = entomologist::issue::Issue::new(issues_dir)?; + issue.edit_description()?; + println!("created new issue '{}'", issue.title()); } } diff --git a/src/issue.rs b/src/issue.rs index 753454b..b91d3c6 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -1,3 +1,4 @@ +use std::io::Write; use std::str::FromStr; #[derive(Debug, PartialEq, serde::Deserialize)] @@ -30,6 +31,10 @@ pub enum IssueError { StdIoError(#[from] std::io::Error), #[error("Failed to parse issue")] IssueParseError, + #[error("Failed to run git")] + GitError(#[from] crate::git::GitError), + #[error("Failed to run editor")] + EditorError, } impl FromStr for State { @@ -95,6 +100,53 @@ impl Issue { }) } + pub fn new(dir: &std::path::Path) -> Result { + let mut issue_dir = std::path::PathBuf::from(dir); + let rnd: u128 = rand::random(); + issue_dir.push(&format!("{:0x}", rnd)); + std::fs::create_dir(&issue_dir)?; + Ok(Self { + description: String::from(""), // FIXME: kind of bogus to use the empty string as None + state: State::New, + dependencies: None, + dir: issue_dir, + }) + } + + pub fn set_description(&mut self, description: &str) -> Result<(), IssueError> { + self.description = String::from(description); + let mut description_filename = std::path::PathBuf::from(&self.dir); + description_filename.push("description"); + let mut description_file = std::fs::File::create(&description_filename)?; + write!(description_file, "{}", description)?; + crate::git::git_commit_file(&description_filename)?; + Ok(()) + } + + pub fn read_description(&mut self) -> Result<(), IssueError> { + 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<(), IssueError> { + let mut description_filename = std::path::PathBuf::from(&self.dir); + description_filename.push("description"); + let result = std::process::Command::new("vi") + .arg(&description_filename.as_mut_os_str()) + .spawn()? + .wait_with_output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(IssueError::EditorError); + } + crate::git::git_commit_file(&description_filename)?; + self.read_description()?; + Ok(()) + } + pub fn title<'a>(&'a self) -> &'a str { match self.description.find("\n") { Some(index) => &self.description.as_str()[..index], From ba0862f5a6d8f59395b5fab2a573118ab8ed4c4e Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 6 Jul 2025 00:18:36 -0600 Subject: [PATCH 016/123] add `ent edit` --- src/bin/ent/main.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index e0dca3c..3d6f80b 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -23,6 +23,9 @@ enum Commands { /// Create a new issue. New { description: Option }, + + /// Edit the description of an issue. + Edit { issue_id: String }, } fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { @@ -46,6 +49,18 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( issue.edit_description()?; println!("created new issue '{}'", issue.title()); } + Commands::Edit { issue_id } => { + let mut issues = + entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + match issues.issues.get_mut(issue_id) { + Some(issue) => { + issue.edit_description()?; + } + None => { + println!("issue {} not found", issue_id); + } + } + } } Ok(()) From 3f2d3b1520a49b50a75b11df0891d771530f69fd Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 6 Jul 2025 00:21:25 -0600 Subject: [PATCH 017/123] add `ent show` --- src/bin/ent/main.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 3d6f80b..3561f4c 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -26,6 +26,9 @@ enum Commands { /// Edit the description of an issue. Edit { issue_id: String }, + + /// Show the full description of an issue. + Show { issue_id: String }, } fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { @@ -61,6 +64,21 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } } } + Commands::Show { issue_id } => { + let issues = + entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + match issues.issues.get(issue_id) { + Some(issue) => { + println!("issue {}", issue_id); + println!("state {:?}", issue.state); + println!(""); + println!("{}", issue.description); + } + None => { + println!("issue {} not found", issue_id); + } + } + } } Ok(()) From 49d7422fbc2cb372a6a44641699f4a2bc18a2754 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 6 Jul 2025 00:29:32 -0600 Subject: [PATCH 018/123] create entomologist issue branch --- README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..2bd9d23 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +This branch is used by entomologist to track issues. \ No newline at end of file From e3bb39b7cc136d97316f72cbdb83f569de39967c Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 6 Jul 2025 00:30:13 -0600 Subject: [PATCH 019/123] update 'description' in issue 8c73c9fd5bc4f551ee5069035ae6e866 --- 8c73c9fd5bc4f551ee5069035ae6e866/description | 1 + 1 file changed, 1 insertion(+) create mode 100644 8c73c9fd5bc4f551ee5069035ae6e866/description 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 From 2d03d40dc1707a2249ee5c45e57cfe4ceef9b327 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 6 Jul 2025 00:30:55 -0600 Subject: [PATCH 020/123] update 'description' in issue da435e5e298b28dc223f9dcfe62a914 --- da435e5e298b28dc223f9dcfe62a914/description | 1 + 1 file changed, 1 insertion(+) create mode 100644 da435e5e298b28dc223f9dcfe62a914/description 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 From 1b17e1e1ed9b9f746d6bafcbe7ae9581413eb329 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 6 Jul 2025 00:31:27 -0600 Subject: [PATCH 021/123] update 'description' in issue 75cefad80aacbf23fc7b9c24a75aa236 --- 75cefad80aacbf23fc7b9c24a75aa236/description | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 75cefad80aacbf23fc7b9c24a75aa236/description 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? From 3f022609c6afc8e3db6f6d8fb70afba9a28ae467 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 6 Jul 2025 00:31:50 -0600 Subject: [PATCH 022/123] update 'description' in issue 7da3bd5b72de0a05936b094db5d24304 --- 7da3bd5b72de0a05936b094db5d24304/description | 1 + 1 file changed, 1 insertion(+) create mode 100644 7da3bd5b72de0a05936b094db5d24304/description 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 From e915d7260200c2287c656a36d8299d4e515bc3e3 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 6 Jul 2025 00:32:09 -0600 Subject: [PATCH 023/123] update 'description' in issue 1f85dfac686d5ea2417b2b07f7e1ff01 --- 1f85dfac686d5ea2417b2b07f7e1ff01/description | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 1f85dfac686d5ea2417b2b07f7e1ff01/description 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 From 2b186e01b2cc1f2c60f941e5e05418390e8fef91 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 6 Jul 2025 00:32:21 -0600 Subject: [PATCH 024/123] update 'description' in issue b738f2842db428df1b4aad0192a7f36c --- b738f2842db428df1b4aad0192a7f36c/description | 1 + 1 file changed, 1 insertion(+) create mode 100644 b738f2842db428df1b4aad0192a7f36c/description 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 From 3023576fec1422cf3e43e5be15daadca8989ceda Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 6 Jul 2025 00:36:51 -0600 Subject: [PATCH 025/123] did some todo items --- Todo.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/Todo.md b/Todo.md index 56728ad..9594c71 100644 --- a/Todo.md +++ b/Todo.md @@ -2,14 +2,6 @@ * migrate this todo list into entomologist -* teach it to work with a git branch - - unpack the branch to a directory with `git worktree ${TMPDIR} ${BRANCH}` - - operate on the issues in that worktree - - git commit the result back to ${BRANCH} - - delete and prune the worktree - -* implement `ent new` - * implement user control over state transitions * implement `ent comment ${ISSUE} [-m ${MESSAGE}]` @@ -18,9 +10,6 @@ - each comment is a file or directory under the `${ISSUE}/comments` - comments are ordered by ctime? -* implement `ent edit ${ISSUE} [-t ${TITLE}] [-d ${DESCRIPTION}]` - - or would it be better to put the title and description together into a new `message`, like git commits? - * implement `ent edit ${COMMENT}` * implement `ent attach ${ISSUE} ${FILE}` From 24df544a035192f0fac1bf6adeb95479993ebc3e Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Mon, 7 Jul 2025 12:03:03 -0600 Subject: [PATCH 026/123] add install.sh script --- install.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100755 install.sh diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..9a459a3 --- /dev/null +++ b/install.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +BINFILE="${SCRIPT_DIR}/target/release/ent" +INSTALL_DIR="/usr/bin" + +cargo build --release +echo "copying ent to ${INSTALL_DIR}" +sudo cp $BINFILE $INSTALL_DIR +echo "ent installed to ${INSTALL_DIR}" From 1e5d328ab4a1e46e013a7388575c26eb16a34367 Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Mon, 7 Jul 2025 12:31:09 -0600 Subject: [PATCH 027/123] add logging crate to reduce unnecessary stdout spam --- Cargo.toml | 6 ++++++ src/bin/ent/main.rs | 6 ++++++ src/issue.rs | 6 +++++- src/issues.rs | 6 +++++- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1f7941d..fccb9c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,11 +3,17 @@ name = "entomologist" version = "0.1.0" edition = "2024" +[features] +default = [] +log = ["dep:log", "dep:simple_logger"] + [dependencies] anyhow = "1.0.95" clap = { version = "4.5.26", features = ["derive"] } +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/src/bin/ent/main.rs b/src/bin/ent/main.rs index 65e36dc..47fa87c 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -1,5 +1,8 @@ use clap::Parser; +#[cfg(feature = "log")] +use simple_logger; + #[derive(Debug, clap::Parser)] #[command(version, about, long_about = None)] struct Args { @@ -49,6 +52,9 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } fn main() -> anyhow::Result<()> { + #[cfg(feature = "log")] + simple_logger::SimpleLogger::new().env().init().unwrap(); + let args: Args = Args::parse(); // println!("{:?}", args); diff --git a/src/issue.rs b/src/issue.rs index 939af47..8008ea9 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -1,5 +1,8 @@ use std::str::FromStr; +#[cfg(feature = "log")] +use log::debug; + #[derive(Debug, PartialEq, serde::Deserialize)] /// These are the states an issue can be in. pub enum State { @@ -74,7 +77,8 @@ impl Issue { dependencies = Some(deps); } } else { - println!("ignoring unknown file in issue directory: {:?}", file_name); + #[cfg(feature = "log")] + debug!("ignoring unknown file in issue directory: {:?}", file_name); } } } diff --git a/src/issues.rs b/src/issues.rs index 9422ded..b45026f 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -1,3 +1,6 @@ +#[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 {} @@ -56,7 +59,8 @@ impl Issues { } else if direntry.file_name() == "config.toml" { issues.parse_config(direntry.path().as_path())?; } else { - println!( + #[cfg(feature = "log")] + debug!( "ignoring unknown file in issues directory: {:?}", direntry.file_name() ); From 15f0ce57d588206cca14cc65c8b54ae4992220ae Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Mon, 7 Jul 2025 12:40:03 -0600 Subject: [PATCH 028/123] install.sh: use /usr/local/bin not /usr/bin --- install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 9a459a3..ba8faf0 100755 --- a/install.sh +++ b/install.sh @@ -2,7 +2,7 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) BINFILE="${SCRIPT_DIR}/target/release/ent" -INSTALL_DIR="/usr/bin" +INSTALL_DIR="/usr/local/bin" cargo build --release echo "copying ent to ${INSTALL_DIR}" From 172055c48048289ff8b25ca8f439864b351342a9 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 12:53:05 -0600 Subject: [PATCH 029/123] always render issue UUIDs as 128 bit hex numbers --- src/git.rs | 4 ++-- src/issue.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/git.rs b/src/git.rs index bb17763..caa5e4b 100644 --- a/src/git.rs +++ b/src/git.rs @@ -213,7 +213,7 @@ mod tests { fn test_create_orphan_branch() { let rnd: u128 = rand::random(); let mut branch = std::string::String::from("entomologist-test-branch-"); - branch.push_str(&format!("{:0x}", rnd)); + branch.push_str(&format!("{:032x}", rnd)); create_orphan_branch(&branch).unwrap(); git_remove_branch(&branch).unwrap(); } @@ -228,7 +228,7 @@ mod tests { fn test_branch_exists_1() { let rnd: u128 = rand::random(); let mut branch = std::string::String::from("entomologist-missing-branch-"); - branch.push_str(&format!("{:0x}", rnd)); + 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 index 8031300..bb8594a 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -107,7 +107,7 @@ impl Issue { pub fn new(dir: &std::path::Path) -> Result { let mut issue_dir = std::path::PathBuf::from(dir); let rnd: u128 = rand::random(); - issue_dir.push(&format!("{:0x}", rnd)); + issue_dir.push(&format!("{:032x}", rnd)); std::fs::create_dir(&issue_dir)?; Ok(Self { description: String::from(""), // FIXME: kind of bogus to use the empty string as None From ed1b4488b2ef09cf10181bb76fbbca95e399818c Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Mon, 7 Jul 2025 12:56:59 -0600 Subject: [PATCH 030/123] issue.rs: add state getter/setter --- src/issue.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/issue.rs b/src/issue.rs index 8031300..d3ecf3d 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -4,7 +4,7 @@ use std::str::FromStr; #[cfg(feature = "log")] use log::debug; -#[derive(Debug, PartialEq, serde::Deserialize)] +#[derive(Clone, Debug, PartialEq, serde::Deserialize)] /// These are the states an issue can be in. pub enum State { New, @@ -157,6 +157,23 @@ impl Issue { None => self.description.as_str(), } } + + pub fn set_state(&mut self, new_state: State) -> Result<(), IssueError> { + 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)?; + crate::git::git_commit_file(&state_filename)?; + 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(()) + } } #[cfg(test)] From a6d2f7d1e83a0f86fd52324b73417925c27d8fc1 Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Mon, 7 Jul 2025 13:07:55 -0600 Subject: [PATCH 031/123] issue.rs: add fmt::Display for State --- src/issue.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/issue.rs b/src/issue.rs index d3ecf3d..61f7072 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -1,3 +1,4 @@ +use core::fmt; use std::io::Write; use std::str::FromStr; @@ -62,6 +63,21 @@ impl FromStr for State { } } +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}") + } +} + impl Issue { pub fn new_from_dir(dir: &std::path::Path) -> Result { let mut description: Option = None; From 0f46eb78172a75a8fca80f119fbe912293f9a1d1 Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Mon, 7 Jul 2025 13:28:26 -0600 Subject: [PATCH 032/123] add State command to CLI --- src/bin/ent/main.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index d26dc3c..6a3c9f0 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -1,5 +1,6 @@ use clap::Parser; +use entomologist::issue::State; #[cfg(feature = "log")] use simple_logger; @@ -32,6 +33,9 @@ enum Commands { /// Show the full description of an issue. Show { issue_id: String }, + + /// Modify the state of an issue + State { issue_id: String, new_state: State }, } fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { @@ -82,6 +86,20 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } } } + Commands::State { issue_id, new_state } => { + let mut issues = + entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + match issues.issues.get_mut(issue_id) { + Some(issue) => { + let old_state = issue.state.clone(); + issue.set_state(new_state.clone())?; + println!("issue {}: state {} -> {}", issue_id, old_state, new_state); + } + None => { + println!("issue {} not found", issue_id); + } + } + } } Ok(()) From bcc8ba4f2147879c95f73913fb69a74ca1141412 Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Mon, 7 Jul 2025 16:49:25 -0600 Subject: [PATCH 033/123] update CLI to have optional state control --- src/bin/ent/main.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 6a3c9f0..187d067 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -35,7 +35,7 @@ enum Commands { Show { issue_id: String }, /// Modify the state of an issue - State { issue_id: String, new_state: State }, + State { issue_id: String, new_state: Option }, } fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { @@ -91,9 +91,20 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; match issues.issues.get_mut(issue_id) { Some(issue) => { - let old_state = issue.state.clone(); - issue.set_state(new_state.clone())?; - println!("issue {}: state {} -> {}", issue_id, old_state, new_state); + let current_state = issue.state.clone(); + + match new_state { + Some(s) => { + issue.set_state(s.clone())?; + println!("issue: {}", issue_id); + println!("state: {} -> {}", current_state, s); + } + None => { + println!("issue: {}", issue_id); + println!("state: {}", current_state); + } + } + } None => { println!("issue {} not found", issue_id); From b789a3d293cb73bdc293c3e3422b5b4ae48fead2 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 15:30:46 -0600 Subject: [PATCH 034/123] ent show: show dependencies, if any --- src/bin/ent/main.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 187d067..b2eaee2 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -77,7 +77,10 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( match issues.issues.get(issue_id) { Some(issue) => { println!("issue {}", issue_id); - println!("state {:?}", issue.state); + println!("state: {:?}", issue.state); + if let Some(dependencies) = &issue.dependencies { + println!("dependencies: {:?}", dependencies); + } println!(""); println!("{}", issue.description); } From 4307ab98a0bede7ed11ba1da176d6fad53b7ceb5 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 16:14:19 -0600 Subject: [PATCH 035/123] better interface to looking up issue --- src/bin/ent/main.rs | 4 ++-- src/issues.rs | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index b2eaee2..83ebb7f 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -62,7 +62,7 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( Commands::Edit { issue_id } => { let mut issues = entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; - match issues.issues.get_mut(issue_id) { + match issues.get_mut_issue(issue_id) { Some(issue) => { issue.edit_description()?; } @@ -74,7 +74,7 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( Commands::Show { issue_id } => { let issues = entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; - match issues.issues.get(issue_id) { + match issues.get_issue(issue_id) { Some(issue) => { println!("issue {}", issue_id); println!("state: {:?}", issue.state); diff --git a/src/issues.rs b/src/issues.rs index a900ed6..2e40930 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -35,6 +35,14 @@ impl Issues { self.issues.insert(uuid, 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)?; From 035c150f4cc9aa271c2cf2a4b380970bfa8454e0 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 16:14:42 -0600 Subject: [PATCH 036/123] ent: better error reporting --- src/bin/ent/main.rs | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 83ebb7f..9c31c60 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -35,7 +35,10 @@ enum Commands { Show { issue_id: String }, /// Modify the state of an issue - State { issue_id: String, new_state: Option }, + State { + issue_id: String, + new_state: Option, + }, } fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { @@ -47,6 +50,7 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( println!("{} {} ({:?})", uuid, issue.title(), issue.state); } } + Commands::New { description: Some(description), } => { @@ -54,11 +58,13 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( issue.set_description(description)?; println!("created new issue '{}'", issue.title()); } + Commands::New { description: None } => { let mut issue = entomologist::issue::Issue::new(issues_dir)?; issue.edit_description()?; println!("created new issue '{}'", issue.title()); } + Commands::Edit { issue_id } => { let mut issues = entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; @@ -67,10 +73,11 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( issue.edit_description()?; } None => { - println!("issue {} not found", issue_id); + return Err(anyhow::anyhow!("issue {} not found", issue_id)); } } } + Commands::Show { issue_id } => { let issues = entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; @@ -85,17 +92,20 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( println!("{}", issue.description); } None => { - println!("issue {} not found", issue_id); + return Err(anyhow::anyhow!("issue {} not found", issue_id)); } } } - Commands::State { issue_id, new_state } => { + + Commands::State { + issue_id, + new_state, + } => { let mut issues = entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; match issues.issues.get_mut(issue_id) { Some(issue) => { - let current_state = issue.state.clone(); - + let current_state = issue.state.clone(); match new_state { Some(s) => { issue.set_state(s.clone())?; @@ -107,10 +117,9 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( println!("state: {}", current_state); } } - } None => { - println!("issue {} not found", issue_id); + return Err(anyhow::anyhow!("issue {} not found", issue_id)); } } } From 50509dcf59219b4bfa7f5633e3f44308bb78e838 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 15:26:26 -0600 Subject: [PATCH 037/123] basic comment support in lib --- src/comment.rs | 104 ++++++++++++++++++ src/issue.rs | 42 +++++++ src/issues.rs | 20 ++++ src/lib.rs | 1 + .../description | 3 + 5 files changed, 170 insertions(+) create mode 100644 src/comment.rs create mode 100644 test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description diff --git a/src/comment.rs b/src/comment.rs new file mode 100644 index 0000000..ab2ced9 --- /dev/null +++ b/src/comment.rs @@ -0,0 +1,104 @@ +use std::io::Write; + +#[derive(Debug, PartialEq)] +pub struct Comment { + pub description: String, + + /// This is the directory that the comment lives in. Only used + /// internally by the entomologist library. + pub dir: std::path::PathBuf, +} + +#[derive(Debug, thiserror::Error)] +pub enum CommentError { + #[error(transparent)] + StdIoError(#[from] std::io::Error), + #[error("Failed to parse comment")] + CommentParseError, + #[error("Failed to run git")] + GitError(#[from] crate::git::GitError), + #[error("Failed to run editor")] + EditorError, +} + +impl Comment { + pub fn new_from_dir(comment_dir: &std::path::Path) -> Result { + let mut description: Option = None; + + for direntry in comment_dir.read_dir()? { + if let Ok(direntry) = direntry { + let file_name = direntry.file_name(); + if file_name == "description" { + description = Some(std::fs::read_to_string(direntry.path())?); + } else { + #[cfg(feature = "log")] + debug!( + "ignoring unknown file in comment directory: {:?}", + file_name + ); + } + } + } + + if description == None { + return Err(CommentError::CommentParseError); + } + + Ok(Self { + description: description.unwrap(), + dir: std::path::PathBuf::from(comment_dir), + }) + } + + pub fn set_description(&mut self, description: &str) -> Result<(), CommentError> { + self.description = String::from(description); + let mut description_filename = std::path::PathBuf::from(&self.dir); + description_filename.push("description"); + let mut description_file = std::fs::File::create(&description_filename)?; + write!(description_file, "{}", description)?; + crate::git::git_commit_file(&description_filename)?; + Ok(()) + } + + pub fn read_description(&mut self) -> Result<(), CommentError> { + let mut description_filename = std::path::PathBuf::from(&self.dir); + description_filename.push("description"); + self.description = std::fs::read_to_string(description_filename)?; + Ok(()) + } + + pub fn edit_description(&mut self) -> Result<(), CommentError> { + let mut description_filename = std::path::PathBuf::from(&self.dir); + description_filename.push("description"); + let result = std::process::Command::new("vi") + .arg(&description_filename.as_mut_os_str()) + .spawn()? + .wait_with_output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(CommentError::EditorError); + } + crate::git::git_commit_file(&description_filename)?; + self.read_description()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_comment_0() { + let comment_dir = + std::path::Path::new("test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347"); + let comment = Comment::new_from_dir(comment_dir).unwrap(); + let expected = Comment { + description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), + + dir: std::path::PathBuf::from(comment_dir), + }; + assert_eq!(comment, expected); + } +} diff --git a/src/issue.rs b/src/issue.rs index f931be3..c762c82 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -23,6 +23,7 @@ pub struct Issue { pub description: String, pub state: State, pub dependencies: Option>, + pub comments: std::collections::HashMap, /// This is the directory that the issue lives in. Only used /// internally by the entomologist library. @@ -33,6 +34,8 @@ pub struct Issue { pub enum IssueError { #[error(transparent)] StdIoError(#[from] std::io::Error), + #[error(transparent)] + CommentError(#[from] crate::comment::CommentError), #[error("Failed to parse issue")] IssueParseError, #[error("Failed to run git")] @@ -83,6 +86,7 @@ impl Issue { let mut description: Option = None; let mut state = State::New; // default state, if not specified in the issue let mut dependencies: Option> = None; + let mut comments = std::collections::HashMap::::new(); for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { @@ -101,6 +105,8 @@ impl Issue { if deps.len() > 0 { dependencies = Some(deps); } + } else if file_name == "comments" && direntry.metadata()?.is_dir() { + Self::read_comments(&mut comments, &direntry.path())?; } else { #[cfg(feature = "log")] debug!("ignoring unknown file in issue directory: {:?}", file_name); @@ -116,10 +122,43 @@ impl Issue { description: description.unwrap(), state: state, dependencies, + comments, dir: std::path::PathBuf::from(dir), }) } + fn read_comments( + comments: &mut std::collections::HashMap, + dir: &std::path::Path, + ) -> Result<(), IssueError> { + for direntry in dir.read_dir()? { + if let Ok(direntry) = direntry { + let uuid = direntry.file_name(); + let comment = crate::comment::Comment::new_from_dir(&direntry.path())?; + comments.insert(String::from(uuid.to_string_lossy()), comment); + } + } + Ok(()) + } + + pub fn new_comment(&mut self) -> Result { + let mut dir = std::path::PathBuf::from(&self.dir); + dir.push("comments"); + if !dir.exists() { + println!("creating {}", dir.to_string_lossy()); + std::fs::create_dir(&dir)?; + } + + let rnd: u128 = rand::random(); + dir.push(&format!("{:032x}", rnd)); + std::fs::create_dir(&dir)?; + + Ok(crate::comment::Comment { + description: String::from(""), // FIXME + dir, + }) + } + pub fn new(dir: &std::path::Path) -> Result { let mut issue_dir = std::path::PathBuf::from(dir); let rnd: u128 = rand::random(); @@ -129,6 +168,7 @@ impl Issue { description: String::from(""), // FIXME: kind of bogus to use the empty string as None state: State::New, dependencies: None, + comments: std::collections::HashMap::::new(), dir: issue_dir, }) } @@ -204,6 +244,7 @@ mod tests { description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), state: State::New, dependencies: None, + comments: std::collections::HashMap::::new(), dir: std::path::PathBuf::from(issue_dir), }; assert_eq!(issue, expected); @@ -217,6 +258,7 @@ mod tests { description: String::from("minimal"), state: State::InProgress, dependencies: None, + comments: std::collections::HashMap::::new(), dir: std::path::PathBuf::from(issue_dir), }; assert_eq!(issue, expected); diff --git a/src/issues.rs b/src/issues.rs index 2e40930..28df2e9 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -99,6 +99,7 @@ mod tests { description: String::from("minimal"), state: crate::issue::State::InProgress, dependencies: None, + comments: std::collections::HashMap::::new(), dir, }, ); @@ -112,6 +113,7 @@ mod tests { description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), state: crate::issue::State::New, dependencies: None, + comments: std::collections::HashMap::::new(), dir, } ); @@ -134,6 +136,7 @@ mod tests { description: String::from("oh yeah we got titles"), state: crate::issue::State::Done, dependencies: None, + comments: std::collections::HashMap::::new(), dir, }, ); @@ -141,12 +144,26 @@ mod tests { let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); + let mut comment_dir = dir.clone(); + let comment_uuid = String::from("9055dac36045fe36545bed7ae7b49347"); + comment_dir.push("comments"); + comment_dir.push(&comment_uuid); + let mut expected_comments = + std::collections::HashMap::::new(); + expected_comments.insert( + String::from(&comment_uuid), + crate::comment::Comment { + description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), + dir: std::path::PathBuf::from(comment_dir), + } + ); expected.add_issue( uuid, crate::issue::Issue { description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), state: crate::issue::State::WontDo, dependencies: None, + comments: expected_comments, dir, }, ); @@ -169,6 +186,7 @@ mod tests { description: String::from("oh yeah we got titles\n"), state: crate::issue::State::Done, dependencies: None, + comments: std::collections::HashMap::::new(), dir, }, ); @@ -182,6 +200,7 @@ mod tests { description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), state: crate::issue::State::WontDo, dependencies: None, + comments: std::collections::HashMap::::new(), dir, }, ); @@ -198,6 +217,7 @@ mod tests { crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), ]), + comments: std::collections::HashMap::::new(), dir, }, ); diff --git a/src/lib.rs b/src/lib.rs index cca1c90..e129eff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod comment; pub mod git; pub mod issue; pub mod issues; diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description new file mode 100644 index 0000000..f9de678 --- /dev/null +++ b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description @@ -0,0 +1,3 @@ +This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561 + +It has multiple lines From 9870d42fdcbad2e62de575dc8e403ba5f4889580 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 15:33:43 -0600 Subject: [PATCH 038/123] ent show: include comments --- src/bin/ent/main.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 9c31c60..4a86883 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -90,6 +90,11 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } println!(""); println!("{}", issue.description); + for (uuid, comment) in issue.comments.iter() { + println!(""); + println!("comment: {}", uuid); + println!("{}", comment.description); + } } None => { return Err(anyhow::anyhow!("issue {} not found", issue_id)); From 8ac4ca4c543ccddc15aef154601f753df5f51c42 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 16:15:01 -0600 Subject: [PATCH 039/123] add `ent comment`, to add a comment on an issue --- src/bin/ent/main.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 4a86883..63fba60 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -39,6 +39,12 @@ enum Commands { issue_id: String, new_state: Option, }, + + /// Create a new comment on an issue. + Comment { + issue_id: String, + description: Option, + }, } fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { @@ -128,6 +134,27 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } } } + + Commands::Comment { + issue_id, + description, + } => { + let mut issues = + entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + let Some(issue) = issues.get_mut_issue(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + println!("found issue {}", issue.title()); + let mut comment = issue.new_comment()?; + match description { + Some(description) => { + comment.set_description(description)?; + } + None => { + comment.edit_description()?; + } + } + } } Ok(()) From cd4eb8206777335cfabbb771430a497e6dfb5f0d Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 22:33:29 -0600 Subject: [PATCH 040/123] remove a useless debug message --- src/bin/ent/main.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 63fba60..1c9ddaa 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -144,7 +144,6 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( let Some(issue) = issues.get_mut_issue(issue_id) else { return Err(anyhow::anyhow!("issue {} not found", issue_id)); }; - println!("found issue {}", issue.title()); let mut comment = issue.new_comment()?; match description { Some(description) => { From 7d9284bf91141f8e834f03ed60f6d577ca32305f Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 22:27:24 -0600 Subject: [PATCH 041/123] `ent list` now accepts a filter, default "state=New,Backlog,Blocked,InProgress" --- src/bin/ent/main.rs | 13 ++++++++++--- src/issue.rs | 7 ++++--- src/lib.rs | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 1c9ddaa..1982feb 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -23,7 +23,11 @@ struct Args { #[derive(clap::Subcommand, Debug)] enum Commands { /// List issues. - List, + List { + /// Filter string, describes issues to include in the list. + #[arg(default_value_t = String::from("state=New,Backlog,Blocked,InProgress"))] + filter: String, + }, /// Create a new issue. New { description: Option }, @@ -49,11 +53,14 @@ enum Commands { fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { match &args.command { - Commands::List => { + Commands::List { filter } => { let issues = entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + let filter = entomologist::parse_filter(filter)?; for (uuid, issue) in issues.issues.iter() { - println!("{} {} ({:?})", uuid, issue.title(), issue.state); + if filter.include_states.contains(&issue.state) { + println!("{} {} ({:?})", uuid, issue.title(), issue.state); + } } } diff --git a/src/issue.rs b/src/issue.rs index c762c82..6ce8e46 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -5,7 +5,7 @@ use std::str::FromStr; #[cfg(feature = "log")] use log::debug; -#[derive(Clone, Debug, PartialEq, serde::Deserialize)] +#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Deserialize)] /// These are the states an issue can be in. pub enum State { New, @@ -38,6 +38,8 @@ pub enum IssueError { CommentError(#[from] crate::comment::CommentError), #[error("Failed to parse issue")] IssueParseError, + #[error("Failed to parse state")] + StateParseError, #[error("Failed to run git")] GitError(#[from] crate::git::GitError), #[error("Failed to run editor")] @@ -61,7 +63,7 @@ impl FromStr for State { } else if s == "wontdo" { Ok(State::WontDo) } else { - Err(IssueError::IssueParseError) + Err(IssueError::StateParseError) } } } @@ -75,7 +77,6 @@ impl fmt::Display for State { State::InProgress => "inprogress", State::Done => "done", State::WontDo => "wontdo", - }; write!(f, "{fmt_str}") } diff --git a/src/lib.rs b/src/lib.rs index e129eff..77a00d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,40 @@ +use std::str::FromStr; + pub mod comment; pub mod git; pub mod issue; pub mod issues; + +#[derive(Debug, thiserror::Error)] +pub enum ParseFilterError { + #[error("Failed to parse filter")] + ParseError, + #[error(transparent)] + IssueParseError(#[from] crate::issue::IssueError), +} + +// FIXME: It's easy to imagine a full dsl for filtering issues, for now +// i'm starting with obvious easy things. Chumsky looks appealing but +// more research is needed. +#[derive(Debug)] +pub struct Filter { + pub include_states: std::collections::HashSet, +} + +// Parses a filter description matching "state=STATE[,STATE*]" +pub fn parse_filter(filter_str: &str) -> Result { + let tokens: Vec<&str> = filter_str.split("=").collect(); + if tokens.len() != 2 { + return Err(ParseFilterError::ParseError); + } + if tokens[0] != "state" { + return Err(ParseFilterError::ParseError); + } + + let mut include_states = std::collections::HashSet::::new(); + for s in tokens[1].split(",") { + include_states.insert(crate::issue::State::from_str(s)?); + } + + Ok(Filter { include_states }) +} From ccabfa4ec8367a9e99d060cdd4979ca85a6823c6 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 10:49:10 -0600 Subject: [PATCH 042/123] remove an old debug log message from Issue::new_comment() --- src/issue.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/issue.rs b/src/issue.rs index c762c82..e099616 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -75,7 +75,6 @@ impl fmt::Display for State { State::InProgress => "inprogress", State::Done => "done", State::WontDo => "wontdo", - }; write!(f, "{fmt_str}") } @@ -145,7 +144,6 @@ impl Issue { let mut dir = std::path::PathBuf::from(&self.dir); dir.push("comments"); if !dir.exists() { - println!("creating {}", dir.to_string_lossy()); std::fs::create_dir(&dir)?; } From 9c54a92152f58d7868023eb2ffcadf9b9e0f4dcb Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 10:18:45 -0600 Subject: [PATCH 043/123] add `ent sync` In a worktree with the `entomologist-data` branch checked out in it: 1. `git fetch REMOTE` 2. `git merge REMOTE/BRANCH` 3. `git push REMOTE BRANCH` Pretty straight-forward. If anything goes wrong we error out and ask the human to help. --- src/bin/ent/main.rs | 27 ++++++++++++++++++++++ src/git.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 1c9ddaa..1363641 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -45,6 +45,15 @@ enum Commands { 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, + }, } fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { @@ -154,6 +163,24 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } } } + + Commands::Sync { remote } => { + if args.issues_dir.is_some() { + return Err(anyhow::anyhow!( + "`sync` operates on a branch, don't specify `issues_dir`" + )); + } + // FIXME: Kinda bogus to re-do this thing we just did in + // `main()`. Maybe `main()` shouldn't create the worktree, + // maybe we should do it here in `handle_command()`? + // That way also each command could decide if it wants a + // read-only worktree or a read/write one. + let branch = match &args.issues_branch { + Some(branch) => branch, + None => "entomologist-data", + }; + entomologist::git::sync(issues_dir, remote, branch)?; + } } Ok(()) diff --git a/src/git.rs b/src/git.rs index caa5e4b..30f703e 100644 --- a/src/git.rs +++ b/src/git.rs @@ -124,6 +124,62 @@ pub fn git_commit_file(file: &std::path::Path) -> Result<(), GitError> { 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: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + 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)?; + + // 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 {:?}", + dir + ); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + 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 {:?}", + dir + ); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + + Ok(()) +} + pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> { { let tmp_worktree = tempfile::tempdir().unwrap(); From 2106c69271716c0c7a76e9a200b96b83764f31c8 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 11:10:03 -0600 Subject: [PATCH 044/123] `ent sync`: report success if it all worked --- src/bin/ent/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 1363641..10d0637 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -180,6 +180,7 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( None => "entomologist-data", }; entomologist::git::sync(issues_dir, remote, branch)?; + println!("synced {:?} with {:?}", branch, remote); } } From be362517fb8c7185df7229dffe7058c717e908a1 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 23:45:03 -0600 Subject: [PATCH 045/123] give Comment a timestamp, display in chronological order This commit makes a couple of changes: - `ent show ISSUE` now displays the Issue's Comments in chronological order - the Comment struct now includes a timestamp, which is the Author Time of the oldest commit that touches the comment's directory - the Issue struct now stores its Comments in a sorted Vec, not in a HashMap - The Comment's uuid moved into the Comment struct itself, instead of being the key in the Issue's HashMap of Comments --- Cargo.toml | 1 + src/bin/ent/main.rs | 5 +++-- src/comment.rs | 13 +++++++++++-- src/git.rs | 28 ++++++++++++++++++++++++++++ src/issue.rs | 21 ++++++++++++--------- src/issues.rs | 20 ++++++++++---------- 6 files changed, 65 insertions(+), 23 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fccb9c9..af271d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ log = ["dep:log", "dep:simple_logger"] [dependencies] anyhow = "1.0.95" +chrono = "0.4.41" clap = { version = "4.5.26", features = ["derive"] } log = { version = "0.4.27", optional = true } rand = "0.9.1" diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 4ecc596..2d0f56a 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -112,9 +112,10 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } println!(""); println!("{}", issue.description); - for (uuid, comment) in issue.comments.iter() { + for comment in &issue.comments { println!(""); - println!("comment: {}", uuid); + println!("comment: {}", comment.uuid); + println!("timestamp: {}", comment.timestamp); println!("{}", comment.description); } } diff --git a/src/comment.rs b/src/comment.rs index ab2ced9..8cd00a0 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -2,6 +2,8 @@ use std::io::Write; #[derive(Debug, PartialEq)] pub struct Comment { + pub uuid: String, + pub timestamp: chrono::DateTime, pub description: String, /// This is the directory that the comment lives in. Only used @@ -39,12 +41,16 @@ impl Comment { } } } - if description == None { return Err(CommentError::CommentParseError); } + let timestamp = crate::git::git_log_oldest_timestamp(comment_dir)?; + let dir = std::path::PathBuf::from(comment_dir); + Ok(Self { + uuid: String::from(dir.file_name().unwrap().to_string_lossy()), + timestamp, description: description.unwrap(), dir: std::path::PathBuf::from(comment_dir), }) @@ -95,8 +101,11 @@ mod tests { std::path::Path::new("test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347"); let comment = Comment::new_from_dir(comment_dir).unwrap(); let expected = Comment { + uuid: String::from("9055dac36045fe36545bed7ae7b49347"), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00") + .unwrap() + .with_timezone(&chrono::Local), description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), - dir: std::path::PathBuf::from(comment_dir), }; assert_eq!(comment, expected); diff --git a/src/git.rs b/src/git.rs index 30f703e..d26293a 100644 --- a/src/git.rs +++ b/src/git.rs @@ -4,6 +4,8 @@ use std::io::Write; pub enum GitError { #[error(transparent)] StdIoError(#[from] std::io::Error), + #[error(transparent)] + ParseIntError(#[from] std::num::ParseIntError), #[error("Oops, something went wrong")] Oops, } @@ -180,6 +182,32 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git 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().unwrap().to_string_lossy(), + ]) + .current_dir(&git_dir) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + let timestamp_str = std::str::from_utf8(&result.stdout).unwrap(); + let timestamp_i64 = timestamp_str.parse::()?; + let timestamp = chrono::DateTime::from_timestamp(timestamp_i64, 0) + .unwrap() + .with_timezone(&chrono::Local); + Ok(timestamp) +} + pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> { { let tmp_worktree = tempfile::tempdir().unwrap(); diff --git a/src/issue.rs b/src/issue.rs index 5f9664f..cdbac1f 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -23,7 +23,7 @@ pub struct Issue { pub description: String, pub state: State, pub dependencies: Option>, - pub comments: std::collections::HashMap, + pub comments: Vec, /// This is the directory that the issue lives in. Only used /// internally by the entomologist library. @@ -87,7 +87,7 @@ impl Issue { let mut description: Option = None; let mut state = State::New; // default state, if not specified in the issue let mut dependencies: Option> = None; - let mut comments = std::collections::HashMap::::new(); + let mut comments = Vec::::new(); for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { @@ -129,16 +129,16 @@ impl Issue { } fn read_comments( - comments: &mut std::collections::HashMap, + comments: &mut Vec, dir: &std::path::Path, ) -> Result<(), IssueError> { for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { - let uuid = direntry.file_name(); let comment = crate::comment::Comment::new_from_dir(&direntry.path())?; - comments.insert(String::from(uuid.to_string_lossy()), comment); + comments.push(comment); } } + comments.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); Ok(()) } @@ -150,10 +150,13 @@ impl Issue { } let rnd: u128 = rand::random(); - dir.push(&format!("{:032x}", rnd)); + let uuid = format!("{:032x}", rnd); + dir.push(&uuid); std::fs::create_dir(&dir)?; Ok(crate::comment::Comment { + uuid, + timestamp: chrono::Local::now(), description: String::from(""), // FIXME dir, }) @@ -168,7 +171,7 @@ impl Issue { description: String::from(""), // FIXME: kind of bogus to use the empty string as None state: State::New, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir: issue_dir, }) } @@ -244,7 +247,7 @@ mod tests { description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), state: State::New, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir: std::path::PathBuf::from(issue_dir), }; assert_eq!(issue, expected); @@ -258,7 +261,7 @@ mod tests { description: String::from("minimal"), state: State::InProgress, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir: std::path::PathBuf::from(issue_dir), }; assert_eq!(issue, expected); diff --git a/src/issues.rs b/src/issues.rs index 28df2e9..06af867 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -99,7 +99,7 @@ mod tests { description: String::from("minimal"), state: crate::issue::State::InProgress, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, }, ); @@ -113,7 +113,7 @@ mod tests { description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), state: crate::issue::State::New, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, } ); @@ -136,7 +136,7 @@ mod tests { description: String::from("oh yeah we got titles"), state: crate::issue::State::Done, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, }, ); @@ -148,12 +148,12 @@ mod tests { let comment_uuid = String::from("9055dac36045fe36545bed7ae7b49347"); comment_dir.push("comments"); comment_dir.push(&comment_uuid); - let mut expected_comments = - std::collections::HashMap::::new(); - expected_comments.insert( - String::from(&comment_uuid), + let mut expected_comments = Vec::::new(); + expected_comments.push( crate::comment::Comment { + uuid: comment_uuid, description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00").unwrap().with_timezone(&chrono::Local), dir: std::path::PathBuf::from(comment_dir), } ); @@ -186,7 +186,7 @@ mod tests { description: String::from("oh yeah we got titles\n"), state: crate::issue::State::Done, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, }, ); @@ -200,7 +200,7 @@ mod tests { description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), state: crate::issue::State::WontDo, dependencies: None, - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, }, ); @@ -217,7 +217,7 @@ mod tests { crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), ]), - comments: std::collections::HashMap::::new(), + comments: Vec::::new(), dir, }, ); From a2c7ce34a363e15b3dac2b0160fb3a449a750900 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 14:45:17 -0600 Subject: [PATCH 046/123] fix git::git_log_oldest_timestamp() when there are multiple log entries --- src/git.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/git.rs b/src/git.rs index d26293a..28e8400 100644 --- a/src/git.rs +++ b/src/git.rs @@ -191,6 +191,7 @@ pub fn git_log_oldest_timestamp( .args([ "log", "--pretty=format:%at", + "--", &path.file_name().unwrap().to_string_lossy(), ]) .current_dir(&git_dir) @@ -201,7 +202,8 @@ pub fn git_log_oldest_timestamp( return Err(GitError::Oops); } let timestamp_str = std::str::from_utf8(&result.stdout).unwrap(); - let timestamp_i64 = timestamp_str.parse::()?; + 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); From 7acd94f7c0e403260b336a7a7998d400b69c14d2 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 14:08:21 -0600 Subject: [PATCH 047/123] add author to Comment --- src/bin/ent/main.rs | 1 + src/comment.rs | 4 ++++ src/git.rs | 22 ++++++++++++++++++++++ src/issue.rs | 1 + src/issues.rs | 3 ++- 5 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 2d0f56a..ac15eb4 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -115,6 +115,7 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( for comment in &issue.comments { println!(""); println!("comment: {}", comment.uuid); + println!("author: {}", comment.author); println!("timestamp: {}", comment.timestamp); println!("{}", comment.description); } diff --git a/src/comment.rs b/src/comment.rs index 8cd00a0..d6ed66e 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -3,6 +3,7 @@ use std::io::Write; #[derive(Debug, PartialEq)] pub struct Comment { pub uuid: String, + pub author: String, pub timestamp: chrono::DateTime, pub description: String, @@ -45,11 +46,13 @@ impl Comment { return Err(CommentError::CommentParseError); } + let author = crate::git::git_log_oldest_author(comment_dir)?; let timestamp = crate::git::git_log_oldest_timestamp(comment_dir)?; let dir = std::path::PathBuf::from(comment_dir); Ok(Self { uuid: String::from(dir.file_name().unwrap().to_string_lossy()), + author, timestamp, description: description.unwrap(), dir: std::path::PathBuf::from(comment_dir), @@ -102,6 +105,7 @@ mod tests { let comment = Comment::new_from_dir(comment_dir).unwrap(); let expected = Comment { uuid: String::from("9055dac36045fe36545bed7ae7b49347"), + author: String::from("Sebastian Kuzminsky "), timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00") .unwrap() .with_timezone(&chrono::Local), diff --git a/src/git.rs b/src/git.rs index 28e8400..6018392 100644 --- a/src/git.rs +++ b/src/git.rs @@ -210,6 +210,28 @@ pub fn git_log_oldest_timestamp( 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().unwrap().to_string_lossy(), + ]) + .current_dir(&git_dir) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(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 create_orphan_branch(branch: &str) -> Result<(), GitError> { { let tmp_worktree = tempfile::tempdir().unwrap(); diff --git a/src/issue.rs b/src/issue.rs index cdbac1f..0e83297 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -156,6 +156,7 @@ impl Issue { Ok(crate::comment::Comment { uuid, + author: String::from("Sebastian Kuzminsky "), timestamp: chrono::Local::now(), description: String::from(""), // FIXME dir, diff --git a/src/issues.rs b/src/issues.rs index 06af867..3be405d 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -152,8 +152,9 @@ mod tests { expected_comments.push( crate::comment::Comment { uuid: comment_uuid, - description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), + author: String::from("Sebastian Kuzminsky "), timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00").unwrap().with_timezone(&chrono::Local), + description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), dir: std::path::PathBuf::from(comment_dir), } ); From e8b37cd86a93aae72ee506fec77133027bc4b0c1 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 14:18:31 -0600 Subject: [PATCH 048/123] add author and timestamp to Issue --- src/bin/ent/main.rs | 2 ++ src/issue.rs | 27 ++++++++++++++++++++++----- src/issues.rs | 42 +++++++++++++++++++++++++++++++++++------- 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index ac15eb4..81870e3 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -106,6 +106,8 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( match issues.get_issue(issue_id) { Some(issue) => { println!("issue {}", issue_id); + println!("author: {}", issue.author); + println!("timestamp: {}", issue.timestamp); println!("state: {:?}", issue.state); if let Some(dependencies) = &issue.dependencies { println!("dependencies: {:?}", dependencies); diff --git a/src/issue.rs b/src/issue.rs index 0e83297..97aa8f1 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -20,9 +20,11 @@ pub type IssueHandle = String; #[derive(Debug, PartialEq)] pub struct Issue { - pub description: String, + pub author: String, + pub timestamp: chrono::DateTime, pub state: State, pub dependencies: Option>, + pub description: String, pub comments: Vec, /// This is the directory that the issue lives in. Only used @@ -119,10 +121,15 @@ impl Issue { return Err(IssueError::IssueParseError); } + let author = crate::git::git_log_oldest_author(dir)?; + let timestamp = crate::git::git_log_oldest_timestamp(dir)?; + Ok(Self { - description: description.unwrap(), + author, + timestamp, state: state, dependencies, + description: description.unwrap(), comments, dir: std::path::PathBuf::from(dir), }) @@ -169,9 +176,11 @@ impl Issue { issue_dir.push(&format!("{:032x}", rnd)); std::fs::create_dir(&issue_dir)?; Ok(Self { - description: String::from(""), // FIXME: kind of bogus to use the empty string as None + author: String::from(""), + timestamp: chrono::Local::now(), state: State::New, dependencies: None, + description: String::from(""), // FIXME: kind of bogus to use the empty string as None comments: Vec::::new(), dir: issue_dir, }) @@ -245,9 +254,13 @@ mod tests { let issue_dir = std::path::Path::new("test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/"); let issue = Issue::new_from_dir(issue_dir).unwrap(); let expected = Issue { - 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"), + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: State::New, dependencies: 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), }; @@ -259,9 +272,13 @@ mod tests { let issue_dir = std::path::Path::new("test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/"); let issue = Issue::new_from_dir(issue_dir).unwrap(); let expected = Issue { - description: String::from("minimal"), + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: State::InProgress, dependencies: None, + description: String::from("minimal"), comments: Vec::::new(), dir: std::path::PathBuf::from(issue_dir), }; diff --git a/src/issues.rs b/src/issues.rs index 3be405d..ff6accb 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -96,9 +96,13 @@ mod tests { expected.add_issue( uuid, crate::issue::Issue { - description: String::from("minimal"), + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: crate::issue::State::InProgress, dependencies: None, + description: String::from("minimal"), comments: Vec::::new(), dir, }, @@ -110,9 +114,13 @@ mod tests { expected.add_issue( uuid, crate::issue::Issue { - 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"), + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: crate::issue::State::New, dependencies: 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, } @@ -133,9 +141,13 @@ mod tests { expected.add_issue( uuid, crate::issue::Issue { - description: String::from("oh yeah we got titles"), + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: crate::issue::State::Done, dependencies: None, + description: String::from("oh yeah we got titles"), comments: Vec::::new(), dir, }, @@ -161,9 +173,13 @@ mod tests { expected.add_issue( uuid, crate::issue::Issue { - description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: crate::issue::State::WontDo, dependencies: 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, }, @@ -184,9 +200,13 @@ mod tests { expected.add_issue( uuid, crate::issue::Issue { - description: String::from("oh yeah we got titles\n"), + author: String::from("sigil-03 "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: crate::issue::State::Done, dependencies: None, + description: String::from("oh yeah we got titles\n"), comments: Vec::::new(), dir, }, @@ -198,9 +218,13 @@ mod tests { expected.add_issue( uuid, crate::issue::Issue { - description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), + author: String::from("sigil-03 "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: crate::issue::State::WontDo, dependencies: 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, }, @@ -212,12 +236,16 @@ mod tests { expected.add_issue( uuid, crate::issue::Issue { - description: String::from("issue with dependencies\n\na test has begun\nfor dependencies we seek\nintertwining life"), + author: String::from("sigil-03 "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") + .unwrap() + .with_timezone(&chrono::Local), state: crate::issue::State::WontDo, dependencies: Some(vec![ crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), ]), + description: String::from("issue with dependencies\n\na test has begun\nfor dependencies we seek\nintertwining life"), comments: Vec::::new(), dir, }, From ba57f629e38995d1973d9447e8d675b83b7af129 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 16:09:45 -0600 Subject: [PATCH 049/123] make `ent list` sort issues first by state, then by ctime --- src/bin/ent/main.rs | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 81870e3..aa15752 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -66,11 +66,44 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( let issues = entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; let filter = entomologist::parse_filter(filter)?; + 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) { - println!("{} {} ({:?})", uuid, issue.title(), issue.state); + 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.timestamp.cmp(&b.timestamp) + }); + println!("{:?}:", state); + for uuid in these_uuids { + let issue = issues.issues.get(*uuid).unwrap(); + println!("{} {}", uuid, issue.title()); + } + println!(""); + } } Commands::New { From 400e0ca26f893697ad5b3691b5117d37047a3fbe Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 16:20:44 -0600 Subject: [PATCH 050/123] ent list: show comment count for each issue --- src/bin/ent/main.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index aa15752..952d178 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -100,7 +100,12 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( println!("{:?}:", state); for uuid in these_uuids { let issue = issues.issues.get(*uuid).unwrap(); - println!("{} {}", uuid, issue.title()); + let num_comments = issue.comments.len(); + if num_comments == 0 { + println!("{} {}", uuid, issue.title()); + } else { + println!("{} 🗩 {} {}", uuid, num_comments, issue.title()); + } } println!(""); } From 645062d10cbdd09c976fc675c6113165e814bef1 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 16:29:00 -0600 Subject: [PATCH 051/123] add optional 'assignee' to Issue --- src/bin/ent/main.rs | 3 +++ src/issue.rs | 10 ++++++++++ src/issues.rs | 7 +++++++ .../7792b063eef6d33e7da5dc1856750c149ba678c6/assignee | 1 + 4 files changed, 21 insertions(+) create mode 100644 test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/assignee diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 952d178..cb8eb9d 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -150,6 +150,9 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( 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 { diff --git a/src/issue.rs b/src/issue.rs index 97aa8f1..096e497 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -24,6 +24,7 @@ pub struct Issue { pub timestamp: chrono::DateTime, pub state: State, pub dependencies: Option>, + pub assignee: Option, pub description: String, pub comments: Vec, @@ -90,6 +91,7 @@ impl Issue { 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; for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { @@ -99,6 +101,10 @@ impl Issue { } 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 == "dependencies" { let dep_strings = std::fs::read_to_string(direntry.path())?; let deps: Vec = dep_strings @@ -129,6 +135,7 @@ impl Issue { timestamp, state: state, dependencies, + assignee, description: description.unwrap(), comments, dir: std::path::PathBuf::from(dir), @@ -180,6 +187,7 @@ impl Issue { timestamp: chrono::Local::now(), 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, @@ -260,6 +268,7 @@ mod tests { .with_timezone(&chrono::Local), 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), @@ -278,6 +287,7 @@ mod tests { .with_timezone(&chrono::Local), 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), diff --git a/src/issues.rs b/src/issues.rs index ff6accb..16dab55 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -102,6 +102,7 @@ mod tests { .with_timezone(&chrono::Local), state: crate::issue::State::InProgress, dependencies: None, + assignee: Some(String::from("beep boop")), description: String::from("minimal"), comments: Vec::::new(), dir, @@ -120,6 +121,7 @@ mod tests { .with_timezone(&chrono::Local), 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, @@ -147,6 +149,7 @@ mod tests { .with_timezone(&chrono::Local), state: crate::issue::State::Done, dependencies: None, + assignee: None, description: String::from("oh yeah we got titles"), comments: Vec::::new(), dir, @@ -179,6 +182,7 @@ mod tests { .with_timezone(&chrono::Local), 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, @@ -206,6 +210,7 @@ mod tests { .with_timezone(&chrono::Local), state: crate::issue::State::Done, dependencies: None, + assignee: None, description: String::from("oh yeah we got titles\n"), comments: Vec::::new(), dir, @@ -224,6 +229,7 @@ mod tests { .with_timezone(&chrono::Local), 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, @@ -245,6 +251,7 @@ mod tests { crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), ]), + assignee: None, description: String::from("issue with dependencies\n\na test has begun\nfor dependencies we seek\nintertwining life"), comments: Vec::::new(), dir, diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/assignee b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/assignee new file mode 100644 index 0000000..fae06e3 --- /dev/null +++ b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/assignee @@ -0,0 +1 @@ +beep boop From d21b811bee46aa0749f964a74fead2923b40a060 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 17:16:17 -0600 Subject: [PATCH 052/123] add `ent assign ISSUE PERSON` --- src/bin/ent/main.rs | 37 +++++++++++++++++++++++++++++++++++++ src/issue.rs | 9 +++++++++ 2 files changed, 46 insertions(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index cb8eb9d..5237281 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -58,6 +58,12 @@ enum Commands { #[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, + }, } fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { @@ -234,6 +240,37 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( entomologist::git::sync(issues_dir, remote, branch)?; println!("synced {:?} with {:?}", branch, remote); } + + Commands::Assign { + issue_id, + new_assignee, + } => { + let mut issues = + entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + let Some(issue) = issues.issues.get_mut(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + match (&issue.assignee, new_assignee) { + (Some(old_assignee), Some(new_assignee)) => { + println!("issue: {}", issue_id); + println!("assignee: {} -> {}", old_assignee, new_assignee); + issue.set_assignee(new_assignee)?; + } + (Some(old_assignee), None) => { + println!("issue: {}", issue_id); + println!("assignee: {}", old_assignee); + } + (None, Some(new_assignee)) => { + println!("issue: {}", issue_id); + println!("assignee: None -> {}", new_assignee); + issue.set_assignee(new_assignee)?; + } + (None, None) => { + println!("issue: {}", issue_id); + println!("assignee: None"); + } + } + } } Ok(()) diff --git a/src/issue.rs b/src/issue.rs index 096e497..68cb8f9 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -251,6 +251,15 @@ impl Issue { self.state = State::from_str(state_string.trim())?; Ok(()) } + + pub fn set_assignee(&mut self, new_assignee: &str) -> Result<(), IssueError> { + 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)?; + crate::git::git_commit_file(&assignee_filename)?; + Ok(()) + } } #[cfg(test)] From a676bd9cdd6876a7de520b5751576b0945f5c8ce Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 17:31:10 -0600 Subject: [PATCH 053/123] ent list: show assignee, if any --- src/bin/ent/main.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 5237281..2a8f1f4 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -106,12 +106,15 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( println!("{:?}:", state); for uuid in these_uuids { let issue = issues.issues.get(*uuid).unwrap(); - let num_comments = issue.comments.len(); - if num_comments == 0 { - println!("{} {}", uuid, issue.title()); - } else { - println!("{} 🗩 {} {}", uuid, num_comments, issue.title()); - } + let comments = match issue.comments.len() { + 0 => String::from(" "), + n => format!("🗩 {}", n), + }; + let assignee = match &issue.assignee { + Some(assignee) => format!(" (👉 {})", assignee), + None => String::from(""), + }; + println!("{} {} {}{}", uuid, comments, issue.title(), assignee); } println!(""); } From df7b5c6aa4a00dfca66dc802f1e813f23f27a5b9 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 8 Jul 2025 22:21:09 -0600 Subject: [PATCH 054/123] add `ent list` filter by assignee I'm not sure about the filter format... There are two independent filters: "state" and "assignee". The "state" filter defaults to including issues whose state is InProgress, Blocked, Backlog, or New. The "assignee" filter defaults to including all issues, assigned or not. The two filters can be independently overridden by the `ent list FILTER` command. FILTER is a string containing chunks separated by ":", like the PATH environment variable. Each chunk is of the form "name=value[,value...]". "name" can be either "state" or "assignee". The "value" arguments to the "state" filter must be one of the valid states, or it's a parse error. The "value" arguments to the "assignee" filter are used to string-compare against the issues "assignee" field, exact matches are accepted and everything else is rejected. A special assignee filter of the empty string matches issues that don't have an assignee. Some examples: * `ent list` shows issues in the states listed above, and don't filter based on assignee at all. * `ent list assignee=seb` shows issues in the states listed above, but only if the assignee is "seb". * `ent list assignee=seb,` shows issues in the states listed above, but only if the assignee is "seb" or if there is no assignee. * `ent list state=done` shows all issues in the Done state. * `ent list state=done:assignee=seb` shows issues in the Done state that are assigned to "seb". --- src/bin/ent/main.rs | 24 ++++++++++++++----- src/lib.rs | 56 +++++++++++++++++++++++++++++++++------------ 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 2a8f1f4..2892d6a 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -71,18 +71,30 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( Commands::List { filter } => { let issues = entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; - let filter = entomologist::parse_filter(filter)?; + let filter = entomologist::Filter::new_from_str(filter)?; 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) { - uuids_by_state - .entry(issue.state.clone()) - .or_default() - .push(uuid); + 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; + } + } + + // 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; diff --git a/src/lib.rs b/src/lib.rs index 77a00d9..b28fb74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,24 +17,50 @@ pub enum ParseFilterError { // i'm starting with obvious easy things. Chumsky looks appealing but // more research is needed. #[derive(Debug)] -pub struct Filter { +pub struct Filter<'a> { pub include_states: std::collections::HashSet, + pub include_assignees: std::collections::HashSet<&'a str>, } -// Parses a filter description matching "state=STATE[,STATE*]" -pub fn parse_filter(filter_str: &str) -> Result { - let tokens: Vec<&str> = filter_str.split("=").collect(); - if tokens.len() != 2 { - return Err(ParseFilterError::ParseError); - } - if tokens[0] != "state" { - return Err(ParseFilterError::ParseError); - } +impl<'a> Filter<'a> { + pub fn new_from_str(filter_str: &'a str) -> Result, ParseFilterError> { + use crate::issue::State; + let mut f = Filter { + include_states: std::collections::HashSet::::from([ + State::InProgress, + State::Blocked, + State::Backlog, + State::New, + ]), + include_assignees: std::collections::HashSet::<&'a str>::new(), + }; - let mut include_states = std::collections::HashSet::::new(); - for s in tokens[1].split(",") { - include_states.insert(crate::issue::State::from_str(s)?); - } + for filter_chunk_str in filter_str.split(":") { + let tokens: Vec<&str> = filter_chunk_str.split("=").collect(); + if tokens.len() != 2 { + return Err(ParseFilterError::ParseError); + } - Ok(Filter { include_states }) + match tokens[0] { + "state" => { + f.include_states.clear(); + for s in tokens[1].split(",") { + f.include_states.insert(crate::issue::State::from_str(s)?); + } + } + "assignee" => { + f.include_assignees.clear(); + for s in tokens[1].split(",") { + f.include_assignees.insert(s); + } + } + _ => { + println!("unknown filter chunk '{}'", filter_chunk_str); + return Err(ParseFilterError::ParseError); + } + } + } + + Ok(f) + } } From da35631f2d3fd84a325de2b7d654ce0bd109c161 Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Wed, 9 Jul 2025 12:51:39 -0600 Subject: [PATCH 055/123] update right-speech-bubble to use left-speech-bubble right-speech-bubble is not part of the recommended unicode emoji presentation set, but left-speech-bubble is and therefore, it's more commonly supported --- src/bin/ent/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 2892d6a..6fb30c9 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -120,7 +120,7 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( let issue = issues.issues.get(*uuid).unwrap(); let comments = match issue.comments.len() { 0 => String::from(" "), - n => format!("🗩 {}", n), + n => format!("🗨️ {}", n), }; let assignee = match &issue.assignee { Some(assignee) => format!(" (👉 {})", assignee), From ca353352f8dced335506ffb4bd839213b64afefb Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 9 Jul 2025 22:03:49 -0600 Subject: [PATCH 056/123] git::Worktree::drop() now force-drops the worktree This avoids leaving prunable worktrees around if we dirtied the worktree and didn't commit. This can happen in the following situation: 1. User runs `ent new`. 2. ent creates a new directory for the issue. 3. ent opens an editor to let the user type in the description of the new issue. The editor saves to `ISSUE/description`. 4. User changes their mind and no longer wants to make a new issue, so they save an empty buffer and exit the editor. 5. ent sees that the file is empty, and returns an error from Issue::edit_description(). 6. ent propagates the error up through program exit, and eventually the git::Worktree struct is dropped. Since the worktree is dirty (it has the new issue dir with an empty description file in it), `git worktree remove` fails. But `git worktree remove --force` works! --- src/git.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/git.rs b/src/git.rs index 6018392..d83389e 100644 --- a/src/git.rs +++ b/src/git.rs @@ -20,9 +20,24 @@ pub struct Worktree { impl Drop for Worktree { fn drop(&mut self) { - let _result = std::process::Command::new("git") - .args(["worktree", "remove", &self.path.path().to_string_lossy()]) + 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); + } + } + } } } From 1509c42734e360d32c235f8e54dda5835f52e82b Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 9 Jul 2025 22:20:37 -0600 Subject: [PATCH 057/123] add git::worktree_is_dirty() This returns Ok(true) if the worktree has any modified files (staged or unstaged), or any added (staged) files. Ok(false) if not. Ignores untracked files. --- src/git.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/git.rs b/src/git.rs index d83389e..54b5735 100644 --- a/src/git.rs +++ b/src/git.rs @@ -106,6 +106,17 @@ pub fn git_branch_exists(branch: &str) -> Result { 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 git_commit_file(file: &std::path::Path) -> Result<(), GitError> { let mut git_dir = std::path::PathBuf::from(file); git_dir.pop(); From ac72251e0e5c9fb12116eb1f71379f02d1eb22e2 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 9 Jul 2025 22:23:02 -0600 Subject: [PATCH 058/123] add git::restore_file() This restores a file from the index to the worktree. --- src/git.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/git.rs b/src/git.rs index 54b5735..17a00bc 100644 --- a/src/git.rs +++ b/src/git.rs @@ -117,6 +117,19 @@ pub fn worktree_is_dirty(dir: &str) -> Result { return Ok(result.stdout.len() > 0); } +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().unwrap()) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + return Ok(()); +} + pub fn git_commit_file(file: &std::path::Path) -> Result<(), GitError> { let mut git_dir = std::path::PathBuf::from(file); git_dir.pop(); From 16de030b8e64b2b3705995d8ad39e8bb13cd99fe Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 9 Jul 2025 21:16:28 -0600 Subject: [PATCH 059/123] add git::add_file() --- src/git.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/git.rs b/src/git.rs index 17a00bc..087b13c 100644 --- a/src/git.rs +++ b/src/git.rs @@ -117,6 +117,19 @@ pub fn worktree_is_dirty(dir: &str) -> Result { return Ok(result.stdout.len() > 0); } +pub fn add_file(file: &std::path::Path) -> Result<(), GitError> { + let result = std::process::Command::new("git") + .args(["add", &file.to_string_lossy()]) + .current_dir(file.parent().unwrap()) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(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()]) From bfdf6178f4dca2d492a9c514f8f7953d89bc1e1b Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 9 Jul 2025 21:16:37 -0600 Subject: [PATCH 060/123] add git::commit() --- src/git.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/git.rs b/src/git.rs index 087b13c..3374542 100644 --- a/src/git.rs +++ b/src/git.rs @@ -143,6 +143,19 @@ pub fn restore_file(file: &std::path::Path) -> Result<(), GitError> { 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: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + 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(); From 211bf92dde1a07dbf15d89285a51c8d3d533d83c Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 9 Jul 2025 22:21:31 -0600 Subject: [PATCH 061/123] Issue: handle empty description from user This fixes issue a26da230276d317e85f9fcca41c19d2e. --- src/issue.rs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/issue.rs b/src/issue.rs index 68cb8f9..7201871 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -47,6 +47,8 @@ pub enum IssueError { GitError(#[from] crate::git::GitError), #[error("Failed to run editor")] EditorError, + #[error("supplied description is empty")] + EmptyDescription, } impl FromStr for State { @@ -195,6 +197,9 @@ impl Issue { } pub fn set_description(&mut self, description: &str) -> Result<(), IssueError> { + if description.len() == 0 { + return Err(IssueError::EmptyDescription); + } self.description = String::from(description); let mut description_filename = std::path::PathBuf::from(&self.dir); description_filename.push("description"); @@ -214,6 +219,7 @@ impl Issue { pub fn edit_description(&mut self) -> Result<(), IssueError> { let mut description_filename = std::path::PathBuf::from(&self.dir); description_filename.push("description"); + let exists = description_filename.exists(); let result = std::process::Command::new("vi") .arg(&description_filename.as_mut_os_str()) .spawn()? @@ -223,8 +229,31 @@ impl Issue { println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(IssueError::EditorError); } - crate::git::git_commit_file(&description_filename)?; - self.read_description()?; + if description_filename.exists() && description_filename.metadata()?.len() > 0 { + crate::git::add_file(&description_filename)?; + } else { + // 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(IssueError::EmptyDescription); + } + if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { + crate::git::commit( + &description_filename.parent().unwrap(), + &format!( + "new description for issue {}", + description_filename + .parent() + .unwrap() + .file_name() + .unwrap() + .to_string_lossy() + ), + )?; + self.read_description()?; + } Ok(()) } From fc658009f5fd51a8987cae057d2690b2278b833c Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 9 Jul 2025 22:34:55 -0600 Subject: [PATCH 062/123] Comment: handle empty description --- src/comment.rs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/comment.rs b/src/comment.rs index d6ed66e..e6c95fd 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -22,6 +22,8 @@ pub enum CommentError { GitError(#[from] crate::git::GitError), #[error("Failed to run editor")] EditorError, + #[error("supplied description is empty")] + EmptyDescription, } impl Comment { @@ -60,6 +62,9 @@ impl Comment { } pub fn set_description(&mut self, description: &str) -> Result<(), CommentError> { + if description.len() == 0 { + return Err(CommentError::EmptyDescription); + } self.description = String::from(description); let mut description_filename = std::path::PathBuf::from(&self.dir); description_filename.push("description"); @@ -79,6 +84,7 @@ impl Comment { pub fn edit_description(&mut self) -> Result<(), CommentError> { let mut description_filename = std::path::PathBuf::from(&self.dir); description_filename.push("description"); + let exists = description_filename.exists(); let result = std::process::Command::new("vi") .arg(&description_filename.as_mut_os_str()) .spawn()? @@ -88,8 +94,31 @@ impl Comment { println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(CommentError::EditorError); } - crate::git::git_commit_file(&description_filename)?; - self.read_description()?; + if description_filename.exists() && description_filename.metadata()?.len() > 0 { + crate::git::add_file(&description_filename)?; + } else { + // 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); + } + if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { + crate::git::commit( + &description_filename.parent().unwrap(), + &format!( + "new description for comment {}", + description_filename + .parent() + .unwrap() + .file_name() + .unwrap() + .to_string_lossy() + ), + )?; + self.read_description()?; + } Ok(()) } } From a199fbc7f71dc0565a6c811735981ec8fc73d829 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 9 Jul 2025 22:09:50 -0600 Subject: [PATCH 063/123] handle aborts in `ent edit ISSUE` The user saving an empty description file is a normal user-initiated abort, not an error. --- src/bin/ent/main.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 6fb30c9..fa18b2a 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -150,9 +150,16 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( let mut issues = entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; match issues.get_mut_issue(issue_id) { - Some(issue) => { - issue.edit_description()?; - } + Some(issue) => match issue.edit_description() { + Err(entomologist::issue::IssueError::EmptyDescription) => { + println!("aborted issue edit"); + return Ok(()); + } + Err(e) => { + return Err(e.into()); + } + Ok(()) => (), + }, None => { return Err(anyhow::anyhow!("issue {} not found", issue_id)); } From acf539c683a6545bd01f866bed0c82fd0fa33efb Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 9 Jul 2025 22:11:27 -0600 Subject: [PATCH 064/123] handle user abort in `ent comment` The user saving an empty description is a normal user-initiated abort, not an error. --- src/bin/ent/main.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index fa18b2a..893534b 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -234,12 +234,20 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( return Err(anyhow::anyhow!("issue {} not found", issue_id)); }; let mut comment = issue.new_comment()?; - match description { - Some(description) => { - comment.set_description(description)?; + let r = match description { + Some(description) => comment.set_description(description), + None => comment.edit_description(), + }; + match r { + Err(entomologist::comment::CommentError::EmptyDescription) => { + println!("aborted new comment"); + return Ok(()); } - None => { - comment.edit_description()?; + Err(e) => { + return Err(e.into()); + } + Ok(()) => { + println!("created new comment {}", &comment.uuid); } } } From e09e4b9cb72608c4eb7f83f7bae433d1c0180b3c Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 9 Jul 2025 22:12:58 -0600 Subject: [PATCH 065/123] simplify `ent new` --- src/bin/ent/main.rs | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 893534b..8bc0817 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -132,18 +132,24 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } } - Commands::New { - description: Some(description), - } => { + Commands::New { description } => { let mut issue = entomologist::issue::Issue::new(issues_dir)?; - issue.set_description(description)?; - println!("created new issue '{}'", issue.title()); - } - - Commands::New { description: None } => { - let mut issue = entomologist::issue::Issue::new(issues_dir)?; - issue.edit_description()?; - println!("created new issue '{}'", issue.title()); + let r = match description { + Some(description) => issue.set_description(description), + None => issue.edit_description(), + }; + match r { + Err(entomologist::issue::IssueError::EmptyDescription) => { + println!("no new issue created"); + return Ok(()); + } + Err(e) => { + return Err(e.into()); + } + Ok(()) => { + println!("created new issue '{}'", issue.title()); + } + } } Commands::Edit { issue_id } => { From 6ea0b98963e184327dc12612c8e5c1cf561a1f05 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 10 Jul 2025 10:01:33 -0600 Subject: [PATCH 066/123] ent show: blank line between comment header and description This makes it a little easier to read. --- src/bin/ent/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 8bc0817..034fc4f 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -194,6 +194,7 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( println!("comment: {}", comment.uuid); println!("author: {}", comment.author); println!("timestamp: {}", comment.timestamp); + println!(""); println!("{}", comment.description); } } From f8d35e13ffb15e77e83a23551a7fd1893dba592e Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 10 Jul 2025 12:39:03 -0600 Subject: [PATCH 067/123] add git::Worktree::new_detached() This makes a detached worktree, useful for read-only operations on the issues database branch. --- src/git.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/git.rs b/src/git.rs index 3374542..9765996 100644 --- a/src/git.rs +++ b/src/git.rs @@ -55,6 +55,25 @@ impl Worktree { 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: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + Ok(Self { path }) + } + pub fn path(&self) -> &std::path::Path { self.path.as_ref() } From ce36626b7a09d436daa5eb14104bf158dc1b736c Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 10 Jul 2025 13:04:55 -0600 Subject: [PATCH 068/123] ent: handle issue database source & worktrees better This makes each command in `handle_command()` handle its own issues database. The commands that only need read-only access to the issues make a detached worktree, while the commands that need read-write access make a normal worktree with the `entomologist-data` branch checked out. This lets us run any number of read-only operations (like `ent list`, `ent show`, etc), even when a long-lived read-write operation (like `ent new` or `ent edit`) is running. Fixes `ent 317ea8ccac1d414cde55771321bdec30`. --- src/bin/ent/main.rs | 201 +++++++++++++++++++++++++++++++------------- 1 file changed, 143 insertions(+), 58 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 034fc4f..8493cf4 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -66,11 +66,92 @@ enum Commands { }, } -fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { +/// 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. +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. +struct IssuesDatabase { + dir: std::path::PathBuf, + + #[allow(dead_code)] + worktree: Option, +} + +enum IssuesDatabaseAccess { + ReadOnly, + ReadWrite, +} + +fn make_issues_database( + issues_database_source: &IssuesDatabaseSource, + access_type: IssuesDatabaseAccess, +) -> anyhow::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 => { + entomologist::git::Worktree::new_detached(branch)? + } + IssuesDatabaseAccess::ReadWrite => entomologist::git::Worktree::new(branch)?, + }; + Ok(IssuesDatabase { + dir: std::path::PathBuf::from(worktree.path()), + worktree: Some(worktree), + }) + } + } +} + +fn read_issues_database( + issues_database_source: &IssuesDatabaseSource, +) -> anyhow::Result { + let issues_database = + make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadOnly)?; + Ok(entomologist::issues::Issues::new_from_dir( + &issues_database.dir, + )?) +} + +fn handle_command( + args: &Args, + issues_database_source: &IssuesDatabaseSource, +) -> anyhow::Result<()> { match &args.command { Commands::List { filter } => { - let issues = - entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + let issues = read_issues_database(issues_database_source)?; let filter = entomologist::Filter::new_from_str(filter)?; let mut uuids_by_state = std::collections::HashMap::< entomologist::issue::State, @@ -133,7 +214,9 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } Commands::New { description } => { - let mut issue = entomologist::issue::Issue::new(issues_dir)?; + let issues_database = + make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadWrite)?; + let mut issue = entomologist::issue::Issue::new(&issues_database.dir)?; let r = match description { Some(description) => issue.set_description(description), None => issue.edit_description(), @@ -148,13 +231,15 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } Ok(()) => { println!("created new issue '{}'", issue.title()); + return Ok(()); } } } Commands::Edit { issue_id } => { - let mut issues = - entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + let issues_database = + make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadWrite)?; + let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; match issues.get_mut_issue(issue_id) { Some(issue) => match issue.edit_description() { Err(entomologist::issue::IssueError::EmptyDescription) => { @@ -173,8 +258,7 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } Commands::Show { issue_id } => { - let issues = - entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + let issues = read_issues_database(issues_database_source)?; match issues.get_issue(issue_id) { Some(issue) => { println!("issue {}", issue_id); @@ -207,36 +291,44 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( Commands::State { issue_id, new_state, - } => { - let mut issues = - entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; - match issues.issues.get_mut(issue_id) { - Some(issue) => { - let current_state = issue.state.clone(); - match new_state { - Some(s) => { - issue.set_state(s.clone())?; - println!("issue: {}", issue_id); - println!("state: {} -> {}", current_state, s); - } - None => { - println!("issue: {}", issue_id); - println!("state: {}", current_state); - } + } => match new_state { + Some(new_state) => { + let issues_database = + make_issues_database(issues_database_source, 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 => { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); + } + None => { + let issues = 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 mut issues = - entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + let issues_database = + make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadWrite)?; + let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; let Some(issue) = issues.get_mut_issue(issue_id) else { return Err(anyhow::anyhow!("issue {} not found", issue_id)); }; @@ -260,30 +352,25 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } Commands::Sync { remote } => { - if args.issues_dir.is_some() { + if let IssuesDatabaseSource::Branch(branch) = issues_database_source { + let issues_database = + make_issues_database(issues_database_source, 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`" )); } - // FIXME: Kinda bogus to re-do this thing we just did in - // `main()`. Maybe `main()` shouldn't create the worktree, - // maybe we should do it here in `handle_command()`? - // That way also each command could decide if it wants a - // read-only worktree or a read/write one. - let branch = match &args.issues_branch { - Some(branch) => branch, - None => "entomologist-data", - }; - entomologist::git::sync(issues_dir, remote, branch)?; - println!("synced {:?} with {:?}", branch, remote); } Commands::Assign { issue_id, new_assignee, } => { - let mut issues = - entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + let issues_database = + make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadWrite)?; + let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; let Some(issue) = issues.issues.get_mut(issue_id) else { return Err(anyhow::anyhow!("issue {} not found", issue_id)); }; @@ -320,26 +407,24 @@ fn main() -> anyhow::Result<()> { let args: Args = Args::parse(); // println!("{:?}", args); - if let (Some(_), Some(_)) = (&args.issues_dir, &args.issues_branch) { - return Err(anyhow::anyhow!( - "don't specify both `--issues-dir` and `--issues-branch`" - )); - } + let issues_database_source = match (&args.issues_dir, &args.issues_branch) { + (Some(dir), None) => IssuesDatabaseSource::Dir(std::path::Path::new(dir)), + (None, Some(branch)) => IssuesDatabaseSource::Branch(branch), + (None, None) => IssuesDatabaseSource::Branch("entomologist-data"), + (Some(_), Some(_)) => { + return Err(anyhow::anyhow!( + "don't specify both `--issues-dir` and `--issues-branch`" + )) + } + }; - if let Some(dir) = &args.issues_dir { - let dir = std::path::Path::new(dir); - handle_command(&args, dir)?; - } else { - let branch = match &args.issues_branch { - Some(branch) => branch, - None => "entomologist-data", - }; + if let IssuesDatabaseSource::Branch(branch) = &issues_database_source { if !entomologist::git::git_branch_exists(branch)? { entomologist::git::create_orphan_branch(branch)?; } - let worktree = entomologist::git::Worktree::new(branch)?; - handle_command(&args, worktree.path())?; } + handle_command(&args, &issues_database_source)?; + Ok(()) } From 7b6efdf9254d430f08c207de6dec21013f3cfb84 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Fri, 11 Jul 2025 10:01:05 -0600 Subject: [PATCH 069/123] rename git::add_file() to just add(), it can add directories too --- src/comment.rs | 2 +- src/git.rs | 2 +- src/issue.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/comment.rs b/src/comment.rs index e6c95fd..c7afe44 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -95,7 +95,7 @@ impl Comment { return Err(CommentError::EditorError); } if description_filename.exists() && description_filename.metadata()?.len() > 0 { - crate::git::add_file(&description_filename)?; + crate::git::add(&description_filename)?; } else { // User saved an empty file, which means they changed their // mind and no longer want to edit the description. diff --git a/src/git.rs b/src/git.rs index 9765996..0ea0ee6 100644 --- a/src/git.rs +++ b/src/git.rs @@ -136,7 +136,7 @@ pub fn worktree_is_dirty(dir: &str) -> Result { return Ok(result.stdout.len() > 0); } -pub fn add_file(file: &std::path::Path) -> Result<(), GitError> { +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().unwrap()) diff --git a/src/issue.rs b/src/issue.rs index 7201871..3ea6afe 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -230,7 +230,7 @@ impl Issue { return Err(IssueError::EditorError); } if description_filename.exists() && description_filename.metadata()?.len() > 0 { - crate::git::add_file(&description_filename)?; + crate::git::add(&description_filename)?; } else { // User saved an empty file, which means they changed their // mind and no longer want to edit the description. From 1477322f81cedde312c1169b1f56f6b2258d2f94 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Fri, 11 Jul 2025 10:06:17 -0600 Subject: [PATCH 070/123] Issue: refactor to simplify & make better git log messages This starts cleaning up the Issue API. * Start separating the public API from the internal API. * Make `Issue::new()` and `Issue::edit_description()` better behaved, simpler, reduce code duplication, and also produce better git log messages. * Update `ent` to call the changed `new()` function. * Add some comments documenting the Issue API. * `ent new` and `ent edit` now use the editor specified by the EDITOR environment variable, if any. Defaults to `vi` if unspecified. --- src/bin/ent/main.rs | 9 +-- src/issue.rs | 151 +++++++++++++++++++++++++++++--------------- 2 files changed, 103 insertions(+), 57 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 8493cf4..1bc66ba 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -216,12 +216,7 @@ fn handle_command( Commands::New { description } => { let issues_database = make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadWrite)?; - let mut issue = entomologist::issue::Issue::new(&issues_database.dir)?; - let r = match description { - Some(description) => issue.set_description(description), - None => issue.edit_description(), - }; - match r { + match entomologist::issue::Issue::new(&issues_database.dir, description) { Err(entomologist::issue::IssueError::EmptyDescription) => { println!("no new issue created"); return Ok(()); @@ -229,7 +224,7 @@ fn handle_command( Err(e) => { return Err(e.into()); } - Ok(()) => { + Ok(issue) => { println!("created new issue '{}'", issue.title()); return Ok(()); } diff --git a/src/issue.rs b/src/issue.rs index 3ea6afe..5167e4b 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -38,6 +38,8 @@ 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("Failed to parse issue")] IssueParseError, @@ -87,6 +89,7 @@ impl fmt::Display for State { } } +// This is the public API of Issue. impl Issue { pub fn new_from_dir(dir: &std::path::Path) -> Result { let mut description: Option = None; @@ -179,12 +182,26 @@ impl Issue { }) } - pub fn new(dir: &std::path::Path) -> Result { + /// 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(); - issue_dir.push(&format!("{:032x}", rnd)); + let issue_id = format!("{:032x}", rnd); + issue_dir.push(&issue_id); std::fs::create_dir(&issue_dir)?; - Ok(Self { + + let mut issue = Self { author: String::from(""), timestamp: chrono::Local::now(), state: State::New, @@ -192,58 +209,38 @@ impl Issue { assignee: None, description: String::from(""), // FIXME: kind of bogus to use the empty string as None comments: Vec::::new(), - dir: issue_dir, - }) - } + dir: issue_dir.clone(), + }; - pub fn set_description(&mut self, description: &str) -> Result<(), IssueError> { - if description.len() == 0 { - return Err(IssueError::EmptyDescription); - } - self.description = String::from(description); - let mut description_filename = std::path::PathBuf::from(&self.dir); - description_filename.push("description"); - let mut description_file = std::fs::File::create(&description_filename)?; - write!(description_file, "{}", description)?; - crate::git::git_commit_file(&description_filename)?; - Ok(()) - } - - pub fn read_description(&mut self) -> Result<(), IssueError> { - 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<(), IssueError> { - let mut description_filename = std::path::PathBuf::from(&self.dir); - description_filename.push("description"); - let exists = description_filename.exists(); - let result = std::process::Command::new("vi") - .arg(&description_filename.as_mut_os_str()) - .spawn()? - .wait_with_output()?; - if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); - return Err(IssueError::EditorError); - } - if description_filename.exists() && description_filename.metadata()?.len() > 0 { - crate::git::add(&description_filename)?; - } else { - // 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)?; + 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)?; } - return Err(IssueError::EmptyDescription); - } + None => issue.edit_description_file()?, + }; + + crate::git::add(&issue_dir)?; + crate::git::commit(&issue_dir, &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(); + crate::git::add(&description_filename)?; if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { crate::git::commit( &description_filename.parent().unwrap(), &format!( - "new description for issue {}", + "edit description of issue {}", description_filename .parent() .unwrap() @@ -252,11 +249,11 @@ impl Issue { .to_string_lossy() ), )?; - self.read_description()?; } 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], @@ -291,6 +288,60 @@ impl Issue { } } +// 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> { + 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: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + 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(()) + } +} + #[cfg(test)] mod tests { use super::*; From ab86e6369c87d0f2f483788808ee92fcecd6c270 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Fri, 11 Jul 2025 11:08:17 -0600 Subject: [PATCH 071/123] Issue::set_state(): better git commit message --- src/issue.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/issue.rs b/src/issue.rs index 5167e4b..9b5ee18 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -261,12 +261,23 @@ impl Issue { } } + /// Change the State of the Issue. pub fn set_state(&mut self, new_state: State) -> Result<(), IssueError> { 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)?; - crate::git::git_commit_file(&state_filename)?; + crate::git::add(&state_filename)?; + if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { + crate::git::commit( + &self.dir, + &format!( + "change state of issue {} to {}", + self.dir.file_name().unwrap().to_string_lossy(), + new_state, + ), + )?; + } Ok(()) } From 1c8d994fd9cb798f1ac6d366e7c294b90a004969 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Fri, 11 Jul 2025 12:39:48 -0600 Subject: [PATCH 072/123] better `ent assign` log message --- src/bin/ent/main.rs | 37 +++++++++++++++++++------------------ src/issue.rs | 18 +++++++++++++++++- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 1bc66ba..11f74a4 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -363,31 +363,32 @@ fn handle_command( issue_id, new_assignee, } => { - let issues_database = - make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadWrite)?; - let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; - let Some(issue) = issues.issues.get_mut(issue_id) else { + let issues = 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)); }; - match (&issue.assignee, new_assignee) { - (Some(old_assignee), Some(new_assignee)) => { - println!("issue: {}", issue_id); + let old_assignee: String = match &original_issue.assignee { + Some(assignee) => assignee.clone(), + None => String::from("None"), + }; + println!("issue: {}", issue_id); + match new_assignee { + Some(new_assignee) => { + let issues_database = make_issues_database( + issues_database_source, + IssuesDatabaseAccess::ReadWrite, + )?; + let mut issues = + entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; + let Some(issue) = issues.get_mut_issue(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; println!("assignee: {} -> {}", old_assignee, new_assignee); issue.set_assignee(new_assignee)?; } - (Some(old_assignee), None) => { - println!("issue: {}", issue_id); + None => { println!("assignee: {}", old_assignee); } - (None, Some(new_assignee)) => { - println!("issue: {}", issue_id); - println!("assignee: None -> {}", new_assignee); - issue.set_assignee(new_assignee)?; - } - (None, None) => { - println!("issue: {}", issue_id); - println!("assignee: None"); - } } } } diff --git a/src/issue.rs b/src/issue.rs index 9b5ee18..d83cf99 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -289,12 +289,28 @@ impl Issue { 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)?; - crate::git::git_commit_file(&assignee_filename)?; + crate::git::add(&assignee_filename)?; + if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { + crate::git::commit( + &self.dir, + &format!( + "change assignee of issue {}, {} -> {}", + self.dir.file_name().unwrap().to_string_lossy(), + old_assignee, + new_assignee, + ), + )?; + } Ok(()) } } From d642004ee08fbc5bac08faae2012d766453b01ef Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Fri, 11 Jul 2025 11:48:39 -0600 Subject: [PATCH 073/123] refactor `ent comment` Instead of a two-step process handled by the application (`Issue::new_comment()` and `Comment::set_description()` or `Comment::edit_description()`), make a simpler-to-use single-step `Issue::add_comment()`. Move the implementation details from Issue to Comment. Better log message when adding a comment. --- src/bin/ent/main.rs | 18 +++---- src/comment.rs | 127 +++++++++++++++++++++++++++++++++----------- src/issue.rs | 26 +++------ 3 files changed, 111 insertions(+), 60 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 11f74a4..d2b5445 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -327,21 +327,21 @@ fn handle_command( let Some(issue) = issues.get_mut_issue(issue_id) else { return Err(anyhow::anyhow!("issue {} not found", issue_id)); }; - let mut comment = issue.new_comment()?; - let r = match description { - Some(description) => comment.set_description(description), - None => comment.edit_description(), - }; - match r { - Err(entomologist::comment::CommentError::EmptyDescription) => { + 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(()) => { - println!("created new comment {}", &comment.uuid); + Ok(comment) => { + println!( + "created new comment {} on issue {}", + &comment.uuid, &issue_id + ); } } } diff --git a/src/comment.rs b/src/comment.rs index c7afe44..e9c3134 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -16,6 +16,8 @@ pub struct Comment { 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")] @@ -61,17 +63,56 @@ impl Comment { }) } - pub fn set_description(&mut self, description: &str) -> Result<(), CommentError> { - if description.len() == 0 { - return Err(CommentError::EmptyDescription); + /// 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)?; } - self.description = String::from(description); - let mut description_filename = std::path::PathBuf::from(&self.dir); - description_filename.push("description"); - let mut description_file = std::fs::File::create(&description_filename)?; - write!(description_file, "{}", description)?; - crate::git::git_commit_file(&description_filename)?; - Ok(()) + + 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 + timestamp: 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().unwrap().to_string_lossy(), + ), + )?; + } + + Ok(comment) } pub fn read_description(&mut self) -> Result<(), CommentError> { @@ -82,11 +123,39 @@ impl Comment { } pub fn edit_description(&mut self) -> Result<(), CommentError> { - let mut description_filename = std::path::PathBuf::from(&self.dir); - description_filename.push("description"); + 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().unwrap(), + &format!( + "edit comment {} on issue FIXME", // FIXME: name the issue that the comment is on + self.dir.file_name().unwrap().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> { + let description_filename = self.description_filename(); let exists = description_filename.exists(); - let result = std::process::Command::new("vi") - .arg(&description_filename.as_mut_os_str()) + + 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() { @@ -94,9 +163,8 @@ impl Comment { println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(CommentError::EditorError); } - if description_filename.exists() && description_filename.metadata()?.len() > 0 { - crate::git::add(&description_filename)?; - } else { + + 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 { @@ -104,25 +172,20 @@ impl Comment { } return Err(CommentError::EmptyDescription); } - if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { - crate::git::commit( - &description_filename.parent().unwrap(), - &format!( - "new description for comment {}", - description_filename - .parent() - .unwrap() - .file_name() - .unwrap() - .to_string_lossy() - ), - )?; - self.read_description()?; - } + 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::*; diff --git a/src/issue.rs b/src/issue.rs index d83cf99..15d97f0 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -161,25 +161,13 @@ impl Issue { Ok(()) } - pub fn new_comment(&mut self) -> Result { - let mut dir = std::path::PathBuf::from(&self.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)?; - - Ok(crate::comment::Comment { - uuid, - author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::Local::now(), - description: String::from(""), // FIXME - dir, - }) + /// 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. From 08e069841809bb052b94de3b7c5c79cb47597bb9 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 10 Jul 2025 16:27:59 -0600 Subject: [PATCH 074/123] git::sync(): show somewhat ugly git logs of stuff fetched and pushed --- src/git.rs | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/git.rs b/src/git.rs index 0ea0ee6..63cb004 100644 --- a/src/git.rs +++ b/src/git.rs @@ -233,6 +233,62 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git 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 {:?}", + dir + ); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + if result.stdout.len() > 0 { + println!("Changes fetched from remote {}:", remote); + println!("{}", std::str::from_utf8(&result.stdout).unwrap()); + 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 {:?}", + dir + ); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + if result.stdout.len() > 0 { + println!("Changes to push to remote {}:", remote); + println!("{}", std::str::from_utf8(&result.stdout).unwrap()); + println!(""); + } + // Merge remote branch into local. let result = std::process::Command::new("git") .args(["merge", &format!("{}/{}", remote, branch)]) From 65316da0bda0e7c488d57ab2dea28e262c5959f2 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Fri, 11 Jul 2025 14:24:57 -0600 Subject: [PATCH 075/123] teach `ent edit` to edit Comments as well as Issues --- src/bin/ent/main.rs | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index d2b5445..655d16a 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -32,8 +32,8 @@ enum Commands { /// Create a new issue. New { description: Option }, - /// Edit the description of an issue. - Edit { issue_id: String }, + /// Edit the description of an Issue or a Comment. + Edit { uuid: String }, /// Show the full description of an issue. Show { issue_id: String }, @@ -231,25 +231,39 @@ fn handle_command( } } - Commands::Edit { issue_id } => { + Commands::Edit { uuid } => { let issues_database = make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadWrite)?; let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; - match issues.get_mut_issue(issue_id) { - Some(issue) => match issue.edit_description() { + 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(()) => (), - }, - None => { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); + 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 } => { From 665f02cbe8fd04e381e40abe145ad5fd27f2afed Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 12 Jul 2025 10:00:22 -0600 Subject: [PATCH 076/123] when changing state, include old state in git log message Before this commit: change state of issue f3990ac13cd93a925f2a66e6a72eb0f2 to backlog After this commit: change state of issue 406e2330695040fed5fdbcaae5d2b331, new -> inprogress --- src/issue.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/issue.rs b/src/issue.rs index 15d97f0..b309cae 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -251,6 +251,7 @@ impl Issue { /// Change the State of the Issue. 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)?; @@ -260,8 +261,9 @@ impl Issue { crate::git::commit( &self.dir, &format!( - "change state of issue {} to {}", + "change state of issue {}, {} -> {}", self.dir.file_name().unwrap().to_string_lossy(), + old_state, new_state, ), )?; From b02807eaca831bdf9e43e27699d28c108147460b Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 12 Jul 2025 10:18:46 -0600 Subject: [PATCH 077/123] Issue: add tags field --- src/issue.rs | 17 +++++++++++++++++ src/issues.rs | 11 +++++++++++ .../tags | 3 +++ 3 files changed, 31 insertions(+) create mode 100644 test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/tags diff --git a/src/issue.rs b/src/issue.rs index b309cae..52c2f0b 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -22,6 +22,7 @@ pub type IssueHandle = String; pub struct Issue { pub author: String, pub timestamp: chrono::DateTime, + pub tags: Vec, pub state: State, pub dependencies: Option>, pub assignee: Option, @@ -97,6 +98,7 @@ impl Issue { let mut dependencies: Option> = None; let mut comments = Vec::::new(); let mut assignee: Option = None; + let mut tags = Vec::::new(); for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { @@ -119,6 +121,13 @@ impl Issue { if deps.len() > 0 { dependencies = Some(deps); } + } else if file_name == "tags" { + let contents = std::fs::read_to_string(direntry.path())?; + tags = contents + .lines() + .filter(|s| s.len() > 0) + .map(|tag| String::from(tag.trim())) + .collect(); } else if file_name == "comments" && direntry.metadata()?.is_dir() { Self::read_comments(&mut comments, &direntry.path())?; } else { @@ -138,6 +147,7 @@ impl Issue { Ok(Self { author, timestamp, + tags, state: state, dependencies, assignee, @@ -192,6 +202,7 @@ impl Issue { let mut issue = Self { author: String::from(""), timestamp: chrono::Local::now(), + tags: Vec::::new(), state: State::New, dependencies: None, assignee: None, @@ -372,6 +383,11 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::from([ + String::from("tag1"), + String::from("TAG2"), + String::from("i-am-also-a-tag") + ]), state: State::New, dependencies: None, assignee: None, @@ -391,6 +407,7 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::new(), state: State::InProgress, dependencies: None, assignee: Some(String::from("beep boop")), diff --git a/src/issues.rs b/src/issues.rs index 16dab55..5c314ac 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -100,6 +100,7 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::new(), state: crate::issue::State::InProgress, dependencies: None, assignee: Some(String::from("beep boop")), @@ -119,6 +120,11 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::from([ + String::from("tag1"), + String::from("TAG2"), + String::from("i-am-also-a-tag") + ]), state: crate::issue::State::New, dependencies: None, assignee: None, @@ -147,6 +153,7 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::new(), state: crate::issue::State::Done, dependencies: None, assignee: None, @@ -180,6 +187,7 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::new(), state: crate::issue::State::WontDo, dependencies: None, assignee: None, @@ -208,6 +216,7 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::new(), state: crate::issue::State::Done, dependencies: None, assignee: None, @@ -227,6 +236,7 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::new(), state: crate::issue::State::WontDo, dependencies: None, assignee: None, @@ -246,6 +256,7 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::new(), state: crate::issue::State::WontDo, dependencies: Some(vec![ crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/tags b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/tags new file mode 100644 index 0000000..04e82a6 --- /dev/null +++ b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/tags @@ -0,0 +1,3 @@ +tag1 +TAG2 +i-am-also-a-tag From 4307fc8941324d20e50548b43a7baec7cbef2e5f Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 12 Jul 2025 11:23:56 -0600 Subject: [PATCH 078/123] ent list: show tags --- src/bin/ent/main.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 655d16a..1d7b87a 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -207,7 +207,32 @@ fn handle_command( Some(assignee) => format!(" (👉 {})", assignee), None => String::from(""), }; - println!("{} {} {}{}", uuid, comments, issue.title(), assignee); + let tags = match &issue.tags.len() { + 0 => String::from(""), + _ => { + // Could use `format!(" {:?}", issue.tags)` + // here, but that results in `["tag1", "TAG2", + // "i-am-also-a-tag"]` and i don't want the + // double-quotes around each tag. + let mut tags = String::from(" ["); + let mut separator = ""; + for tag in &issue.tags { + tags.push_str(separator); + tags.push_str(tag); + separator = ", "; + } + tags.push_str("]"); + tags + } + }; + println!( + "{} {} {}{}{}", + uuid, + comments, + issue.title(), + assignee, + tags + ); } println!(""); } From 2f1636db55f9a387772e3dd8350faa76581d7724 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 12 Jul 2025 12:26:27 -0600 Subject: [PATCH 079/123] add Issue::add_tag() and Issue::remove_tag() --- src/issue.rs | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/issue.rs b/src/issue.rs index 52c2f0b..8cdbd4f 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -52,6 +52,8 @@ pub enum IssueError { EditorError, #[error("supplied description is empty")] EmptyDescription, + #[error("tag {0} not found")] + TagNotFound(String), } impl FromStr for State { @@ -314,6 +316,37 @@ impl Issue { } Ok(()) } + + /// Add a new Tag to the Issue. Commits. + pub fn add_tag(&mut self, tag: &str) -> Result<(), IssueError> { + let tag_string = String::from(tag); + if self.tags.contains(&tag_string) { + return Ok(()); + } + self.tags.push(tag_string); + self.tags.sort(); + self.commit_tags(&format!( + "issue {} add tag {}", + self.dir.file_name().unwrap().to_string_lossy(), + tag + ))?; + Ok(()) + } + + /// Remove a Tag from the Issue. Commits. + pub fn remove_tag(&mut self, tag: &str) -> Result<(), IssueError> { + let tag_string = String::from(tag); + let Some(index) = self.tags.iter().position(|x| x == &tag_string) else { + return Err(IssueError::TagNotFound(tag_string)); + }; + self.tags.remove(index); + self.commit_tags(&format!( + "issue {} remove tag {}", + self.dir.file_name().unwrap().to_string_lossy(), + tag + ))?; + Ok(()) + } } // This is the internal/private API of Issue. @@ -368,6 +401,18 @@ impl Issue { self.read_description()?; Ok(()) } + + fn commit_tags(&self, commit_message: &str) -> Result<(), IssueError> { + let mut tags_filename = self.dir.clone(); + tags_filename.push("tags"); + let mut tags_file = std::fs::File::create(&tags_filename)?; + for tag in &self.tags { + writeln!(tags_file, "{}", tag)?; + } + crate::git::add(&tags_filename)?; + crate::git::commit(&self.dir, commit_message)?; + Ok(()) + } } #[cfg(test)] From 88025c5daead2f000a0733832ae2e3291bc50a6a Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 12 Jul 2025 10:30:14 -0600 Subject: [PATCH 080/123] add `ent tag ISSUE [[-]TAG] --- src/bin/ent/main.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 1d7b87a..9c87234 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -64,6 +64,13 @@ enum Commands { issue_id: String, new_assignee: Option, }, + + /// Add or remove a Tag to/from an Issue, or list the Tags on an Issue. + Tag { + issue_id: String, + #[arg(allow_hyphen_values = true)] + tag: Option, + }, } /// The main function looks at the command-line arguments and determines @@ -430,6 +437,51 @@ fn handle_command( } } } + + Commands::Tag { issue_id, tag } => { + let issues = read_issues_database(issues_database_source)?; + let Some(issue) = issues.issues.get(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + match tag { + Some(tag) => { + // Add or remove tag. + let issues_database = make_issues_database( + issues_database_source, + IssuesDatabaseAccess::ReadWrite, + )?; + let mut issues = + entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; + let Some(issue) = issues.get_mut_issue(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + if tag.len() == 0 { + return Err(anyhow::anyhow!("invalid zero-length tag")); + } + if tag.chars().nth(0).unwrap() == '-' { + let tag = &tag[1..]; + issue.remove_tag(tag)?; + } else { + issue.add_tag(tag)?; + } + } + None => { + // Just list the tags. + match &issue.tags.len() { + 0 => println!("no tags"), + _ => { + // Could use `format!(" {:?}", issue.tags)` + // here, but that results in `["tag1", "TAG2", + // "i-am-also-a-tag"]` and i don't want the + // double-quotes around each tag. + for tag in &issue.tags { + println!("{}", tag); + } + } + } + } + } + } } Ok(()) From 28db7669f4113fed431c4b4b9fa74d55e16e1ca0 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 12 Jul 2025 14:24:20 -0600 Subject: [PATCH 081/123] ent: better `ent list --help` --- Cargo.toml | 2 +- src/bin/ent/main.rs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index af271d7..864691a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ log = ["dep:log", "dep:simple_logger"] [dependencies] anyhow = "1.0.95" chrono = "0.4.41" -clap = { version = "4.5.26", features = ["derive"] } +clap = { version = "4.5.26", features = ["derive", "wrap_help"] } log = { version = "0.4.27", optional = true } rand = "0.9.1" serde = { version = "1.0.217", features = ["derive"] } diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 9c87234..e09b1a9 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -25,6 +25,15 @@ enum Commands { /// List issues. List { /// Filter string, describes issues to include in the list. + /// The filter string is composed of chunks separated by ":". + /// Each chunk is of the form "name=condition". The supported + /// names and their matching conditions are: + /// + /// "state": Comma-separated list of states to list. + /// + /// "assignee": Comma-separated list of assignees to list. + /// Defaults to all assignees if not set. + /// #[arg(default_value_t = String::from("state=New,Backlog,Blocked,InProgress"))] filter: String, }, From 9d4409c00842a933d61b3a081b72c758e263c8dd Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 12 Jul 2025 14:24:40 -0600 Subject: [PATCH 082/123] `ent list`: add filtering based on tags --- src/bin/ent/main.rs | 15 +++++++++++++++ src/issue.rs | 14 ++++++++++++++ src/lib.rs | 21 +++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index e09b1a9..d67ef3c 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -34,6 +34,10 @@ enum Commands { /// "assignee": Comma-separated list of assignees to list. /// Defaults to all assignees if not set. /// + /// "tag": Comma-separated list of tags to include or exclude + /// (if prefixed with "-"). If omitted, defaults to including + /// all tags and excluding none. + /// #[arg(default_value_t = String::from("state=New,Backlog,Blocked,InProgress"))] filter: String, }, @@ -187,6 +191,17 @@ fn handle_command( } } + if filter.include_tags.len() > 0 { + if !issue.has_any_tag(&filter.include_tags) { + continue; + } + } + if filter.exclude_tags.len() > 0 { + if issue.has_any_tag(&filter.exclude_tags) { + continue; + } + } + // This issue passed all the filters, include it in list. uuids_by_state .entry(issue.state.clone()) diff --git a/src/issue.rs b/src/issue.rs index 8cdbd4f..bd0632c 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -347,6 +347,20 @@ impl Issue { ))?; Ok(()) } + + pub fn has_tag(&self, tag: &str) -> bool { + let tag_string = String::from(tag); + self.tags.iter().position(|x| x == &tag_string).is_some() + } + + pub fn has_any_tag(&self, tags: &std::collections::HashSet<&str>) -> bool { + for tag in tags.iter() { + if self.has_tag(tag) { + return true; + } + } + return false; + } } // This is the internal/private API of Issue. diff --git a/src/lib.rs b/src/lib.rs index b28fb74..fa820b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,8 @@ pub enum ParseFilterError { pub struct Filter<'a> { pub include_states: std::collections::HashSet, pub include_assignees: std::collections::HashSet<&'a str>, + pub include_tags: std::collections::HashSet<&'a str>, + pub exclude_tags: std::collections::HashSet<&'a str>, } impl<'a> Filter<'a> { @@ -33,6 +35,8 @@ impl<'a> Filter<'a> { State::New, ]), include_assignees: std::collections::HashSet::<&'a str>::new(), + include_tags: std::collections::HashSet::<&'a str>::new(), + exclude_tags: std::collections::HashSet::<&'a str>::new(), }; for filter_chunk_str in filter_str.split(":") { @@ -48,12 +52,29 @@ impl<'a> Filter<'a> { f.include_states.insert(crate::issue::State::from_str(s)?); } } + "assignee" => { f.include_assignees.clear(); for s in tokens[1].split(",") { f.include_assignees.insert(s); } } + + "tag" => { + f.include_tags.clear(); + f.exclude_tags.clear(); + for s in tokens[1].split(",") { + if s.len() == 0 { + return Err(ParseFilterError::ParseError); + } + if s.chars().nth(0).unwrap() == '-' { + f.exclude_tags.insert(&s[1..]); + } else { + f.include_tags.insert(s); + } + } + } + _ => { println!("unknown filter chunk '{}'", filter_chunk_str); return Err(ParseFilterError::ParseError); From 3721483c2dbdd01ec6cbc6ba86ca84f8d7c2859a Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 12 Jul 2025 14:48:04 -0600 Subject: [PATCH 083/123] git::sync(): more helpful error message when merge fails --- src/git.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/git.rs b/src/git.rs index 63cb004..fe0446a 100644 --- a/src/git.rs +++ b/src/git.rs @@ -251,7 +251,7 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git if !result.status.success() { println!( "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}", - dir + branch ); println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); @@ -277,7 +277,7 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git if !result.status.success() { println!( "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}", - dir + branch ); println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); @@ -297,7 +297,7 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git if !result.status.success() { println!( "Sync failed! Merge error! Help, a human needs to fix the mess in {:?}", - dir + branch ); println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); @@ -312,7 +312,7 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git if !result.status.success() { println!( "Sync failed! Push error! Help, a human needs to fix the mess in {:?}", - dir + branch ); println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); From 8d5105e4d5183e8cd3a826d69bd2369c8f218cc8 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 12 Jul 2025 14:54:23 -0600 Subject: [PATCH 084/123] flesh out the README some --- README.md | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3ca1a4e..71a2f8e 100644 --- a/README.md +++ b/README.md @@ -1 +1,81 @@ -This is a distributed, collaborative bug tracker, backed by git. +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. From 490f946ef6bca53f965514235d1325f6a5c4cab6 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 13 Jul 2025 10:38:29 -0600 Subject: [PATCH 085/123] don't open an editor is stdin or stdout is not a terminal --- src/comment.rs | 8 +++++++- src/issue.rs | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/comment.rs b/src/comment.rs index e9c3134..c8e26c9 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -1,4 +1,4 @@ -use std::io::Write; +use std::io::{IsTerminal, Write}; #[derive(Debug, PartialEq)] pub struct Comment { @@ -26,6 +26,8 @@ pub enum CommentError { EditorError, #[error("supplied description is empty")] EmptyDescription, + #[error("stdin/stdout is not a terminal")] + StdioIsNotTerminal, } impl Comment { @@ -146,6 +148,10 @@ impl Comment { /// 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(); diff --git a/src/issue.rs b/src/issue.rs index bd0632c..1558578 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -1,5 +1,5 @@ use core::fmt; -use std::io::Write; +use std::io::{IsTerminal, Write}; use std::str::FromStr; #[cfg(feature = "log")] @@ -54,6 +54,8 @@ pub enum IssueError { EmptyDescription, #[error("tag {0} not found")] TagNotFound(String), + #[error("stdin/stdout is not a terminal")] + StdioIsNotTerminal, } impl FromStr for State { @@ -385,6 +387,10 @@ impl Issue { /// 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") { From d2df1bc1e84667c1e379568935e5448b23cb4788 Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Sun, 13 Jul 2025 14:06:04 -0600 Subject: [PATCH 086/123] move entomologist database to new library file fixes: d3a705245bd69aa56524b80b5ae0bc26 --- src/bin/ent/main.rs | 119 ++++++++------------------------------------ src/database.rs | 82 ++++++++++++++++++++++++++++++ src/lib.rs | 1 + 3 files changed, 103 insertions(+), 99 deletions(-) create mode 100644 src/database.rs diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index d67ef3c..b211383 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -86,92 +86,13 @@ enum Commands { }, } -/// 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. -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. -struct IssuesDatabase { - dir: std::path::PathBuf, - - #[allow(dead_code)] - worktree: Option, -} - -enum IssuesDatabaseAccess { - ReadOnly, - ReadWrite, -} - -fn make_issues_database( - issues_database_source: &IssuesDatabaseSource, - access_type: IssuesDatabaseAccess, -) -> anyhow::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 => { - entomologist::git::Worktree::new_detached(branch)? - } - IssuesDatabaseAccess::ReadWrite => entomologist::git::Worktree::new(branch)?, - }; - Ok(IssuesDatabase { - dir: std::path::PathBuf::from(worktree.path()), - worktree: Some(worktree), - }) - } - } -} - -fn read_issues_database( - issues_database_source: &IssuesDatabaseSource, -) -> anyhow::Result { - let issues_database = - make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadOnly)?; - Ok(entomologist::issues::Issues::new_from_dir( - &issues_database.dir, - )?) -} - fn handle_command( args: &Args, - issues_database_source: &IssuesDatabaseSource, + issues_database_source: &entomologist::database::IssuesDatabaseSource, ) -> anyhow::Result<()> { match &args.command { Commands::List { filter } => { - let issues = read_issues_database(issues_database_source)?; + let issues = entomologist::database::read_issues_database(issues_database_source)?; let filter = entomologist::Filter::new_from_str(filter)?; let mut uuids_by_state = std::collections::HashMap::< entomologist::issue::State, @@ -271,7 +192,7 @@ fn handle_command( Commands::New { description } => { let issues_database = - make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadWrite)?; + 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"); @@ -289,7 +210,7 @@ fn handle_command( Commands::Edit { uuid } => { let issues_database = - make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadWrite)?; + 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() { @@ -323,7 +244,7 @@ fn handle_command( } Commands::Show { issue_id } => { - let issues = read_issues_database(issues_database_source)?; + let issues = entomologist::database::read_issues_database(issues_database_source)?; match issues.get_issue(issue_id) { Some(issue) => { println!("issue {}", issue_id); @@ -359,7 +280,7 @@ fn handle_command( } => match new_state { Some(new_state) => { let issues_database = - make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadWrite)?; + 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) => { @@ -374,7 +295,7 @@ fn handle_command( } } None => { - let issues = read_issues_database(issues_database_source)?; + let issues = entomologist::database::read_issues_database(issues_database_source)?; match issues.issues.get(issue_id) { Some(issue) => { println!("issue: {}", issue_id); @@ -392,7 +313,7 @@ fn handle_command( description, } => { let issues_database = - make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadWrite)?; + 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)); @@ -417,9 +338,9 @@ fn handle_command( } Commands::Sync { remote } => { - if let IssuesDatabaseSource::Branch(branch) = issues_database_source { + if let entomologist::database::IssuesDatabaseSource::Branch(branch) = issues_database_source { let issues_database = - make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadWrite)?; + 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 { @@ -433,7 +354,7 @@ fn handle_command( issue_id, new_assignee, } => { - let issues = read_issues_database(issues_database_source)?; + 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)); }; @@ -444,9 +365,9 @@ fn handle_command( println!("issue: {}", issue_id); match new_assignee { Some(new_assignee) => { - let issues_database = make_issues_database( + let issues_database = entomologist::database::make_issues_database( issues_database_source, - IssuesDatabaseAccess::ReadWrite, + entomologist::database::IssuesDatabaseAccess::ReadWrite, )?; let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; @@ -463,16 +384,16 @@ fn handle_command( } Commands::Tag { issue_id, tag } => { - let issues = read_issues_database(issues_database_source)?; + 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 tag { Some(tag) => { // Add or remove tag. - let issues_database = make_issues_database( + let issues_database = entomologist::database::make_issues_database( issues_database_source, - IssuesDatabaseAccess::ReadWrite, + entomologist::database::IssuesDatabaseAccess::ReadWrite, )?; let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; @@ -519,9 +440,9 @@ fn main() -> anyhow::Result<()> { // println!("{:?}", args); let issues_database_source = match (&args.issues_dir, &args.issues_branch) { - (Some(dir), None) => IssuesDatabaseSource::Dir(std::path::Path::new(dir)), - (None, Some(branch)) => IssuesDatabaseSource::Branch(branch), - (None, None) => IssuesDatabaseSource::Branch("entomologist-data"), + (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`" @@ -529,7 +450,7 @@ fn main() -> anyhow::Result<()> { } }; - if let IssuesDatabaseSource::Branch(branch) = &issues_database_source { + if let entomologist::database::IssuesDatabaseSource::Branch(branch) = &issues_database_source { if !entomologist::git::git_branch_exists(branch)? { entomologist::git::create_orphan_branch(branch)?; } diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..06e8e1b --- /dev/null +++ b/src/database.rs @@ -0,0 +1,82 @@ + +/// 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, +) -> anyhow::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, +) -> anyhow::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/lib.rs b/src/lib.rs index fa820b4..17104ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod comment; pub mod git; pub mod issue; pub mod issues; +pub mod database; #[derive(Debug, thiserror::Error)] pub enum ParseFilterError { From 61df7ede8f77ccf58f9e75a8b1d74a468125ec7e Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Sun, 13 Jul 2025 14:21:28 -0600 Subject: [PATCH 087/123] add better error handling --- src/database.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/database.rs b/src/database.rs index 06e8e1b..8afcd8d 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,3 +1,15 @@ +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. @@ -50,7 +62,7 @@ pub enum IssuesDatabaseAccess { pub fn make_issues_database( issues_database_source: &IssuesDatabaseSource, access_type: IssuesDatabaseAccess, -) -> anyhow::Result { +) -> Result { match issues_database_source { IssuesDatabaseSource::Dir(dir) => Ok(IssuesDatabase { dir: std::path::PathBuf::from(dir), @@ -73,7 +85,7 @@ pub fn make_issues_database( pub fn read_issues_database( issues_database_source: &IssuesDatabaseSource, -) -> anyhow::Result { +) -> Result { let issues_database = make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadOnly)?; Ok(crate::issues::Issues::new_from_dir( From 733100fefb4f250b36d84716f72572e098aad711 Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Tue, 15 Jul 2025 10:53:52 -0600 Subject: [PATCH 088/123] add the ID field back into the Issue struct --- src/issue.rs | 23 ++++++++++- src/issues.rs | 110 ++++++++++++++++++++++---------------------------- 2 files changed, 70 insertions(+), 63 deletions(-) diff --git a/src/issue.rs b/src/issue.rs index 1558578..0de413c 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -20,6 +20,7 @@ pub type IssueHandle = String; #[derive(Debug, PartialEq)] pub struct Issue { + pub id: String, pub author: String, pub timestamp: chrono::DateTime, pub tags: Vec, @@ -56,6 +57,8 @@ pub enum IssueError { TagNotFound(String), #[error("stdin/stdout is not a terminal")] StdioIsNotTerminal, + #[error("Failed to parse issue ID")] + IdError, } impl FromStr for State { @@ -145,10 +148,21 @@ impl Issue { 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 = crate::git::git_log_oldest_author(dir)?; let timestamp = crate::git::git_log_oldest_timestamp(dir)?; Ok(Self { + id, author, timestamp, tags, @@ -204,6 +218,7 @@ impl Issue { std::fs::create_dir(&issue_dir)?; let mut issue = Self { + id: String::from(&issue_id), author: String::from(""), timestamp: chrono::Local::now(), tags: Vec::::new(), @@ -444,6 +459,7 @@ mod tests { let issue_dir = std::path::Path::new("test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/"); let issue = Issue::new_from_dir(issue_dir).unwrap(); let expected = Issue { + id: String::from("3943fc5c173fdf41c0a22251593cd476d96e6c9f"), author: String::from("Sebastian Kuzminsky "), timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() @@ -451,12 +467,14 @@ mod tests { tags: Vec::::from([ String::from("tag1"), String::from("TAG2"), - String::from("i-am-also-a-tag") + String::from("i-am-also-a-tag"), ]), 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"), + 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), }; @@ -468,6 +486,7 @@ mod tests { let issue_dir = std::path::Path::new("test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/"); let issue = Issue::new_from_dir(issue_dir).unwrap(); let expected = Issue { + id: String::from("7792b063eef6d33e7da5dc1856750c149ba678c6"), author: String::from("Sebastian Kuzminsky "), timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() diff --git a/src/issues.rs b/src/issues.rs index 5c314ac..42e964d 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -31,8 +31,8 @@ impl Issues { } } - pub fn add_issue(&mut self, uuid: String, issue: crate::issue::Issue) { - self.issues.insert(uuid, issue); + 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> { @@ -56,14 +56,8 @@ impl Issues { for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { if direntry.metadata()?.is_dir() { - let uuid = match direntry.file_name().into_string() { - Ok(uuid) => uuid, - Err(orig_string) => { - return Err(ReadIssuesError::FilenameError(orig_string)) - } - }; let issue = crate::issue::Issue::new_from_dir(direntry.path().as_path())?; - issues.add_issue(uuid, issue); + issues.add_issue(issue); } else if direntry.file_name() == "config.toml" { issues.parse_config(direntry.path().as_path())?; } else { @@ -93,29 +87,27 @@ mod tests { let uuid = String::from("7792b063eef6d33e7da5dc1856750c149ba678c6"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); - expected.add_issue( - uuid, - crate::issue::Issue { - author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") - .unwrap() - .with_timezone(&chrono::Local), - tags: Vec::::new(), - state: crate::issue::State::InProgress, - dependencies: None, - assignee: Some(String::from("beep boop")), - description: String::from("minimal"), - comments: Vec::::new(), - dir, - }, - ); + expected.add_issue(crate::issue::Issue { + id: uuid, + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + .unwrap() + .with_timezone(&chrono::Local), + tags: Vec::::new(), + state: crate::issue::State::InProgress, + dependencies: None, + assignee: Some(String::from("beep boop")), + description: String::from("minimal"), + comments: Vec::::new(), + dir, + }); let uuid = String::from("3943fc5c173fdf41c0a22251593cd476d96e6c9f"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); expected.add_issue( - uuid, crate::issue::Issue { + id: uuid, author: String::from("Sebastian Kuzminsky "), timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() @@ -146,22 +138,20 @@ mod tests { let uuid = String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); - expected.add_issue( - uuid, - crate::issue::Issue { - author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") - .unwrap() - .with_timezone(&chrono::Local), - tags: Vec::::new(), - state: crate::issue::State::Done, - dependencies: None, - assignee: None, - description: String::from("oh yeah we got titles"), - comments: Vec::::new(), - dir, - }, - ); + expected.add_issue(crate::issue::Issue { + id: uuid, + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") + .unwrap() + .with_timezone(&chrono::Local), + tags: Vec::::new(), + state: crate::issue::State::Done, + dependencies: None, + assignee: None, + description: String::from("oh yeah we got titles"), + comments: Vec::::new(), + dir, + }); let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"); let mut dir = std::path::PathBuf::from(issues_dir); @@ -181,8 +171,8 @@ mod tests { } ); expected.add_issue( - uuid, crate::issue::Issue { + id: uuid, author: String::from("Sebastian Kuzminsky "), timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") .unwrap() @@ -209,29 +199,27 @@ mod tests { let uuid = String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); - expected.add_issue( - uuid, - crate::issue::Issue { - author: String::from("sigil-03 "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") - .unwrap() - .with_timezone(&chrono::Local), - tags: Vec::::new(), - state: crate::issue::State::Done, - dependencies: None, - assignee: None, - description: String::from("oh yeah we got titles\n"), - comments: Vec::::new(), - dir, - }, - ); + expected.add_issue(crate::issue::Issue { + id: uuid, + author: String::from("sigil-03 "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") + .unwrap() + .with_timezone(&chrono::Local), + tags: Vec::::new(), + state: crate::issue::State::Done, + dependencies: None, + assignee: None, + description: String::from("oh yeah we got titles\n"), + comments: Vec::::new(), + dir, + }); let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); expected.add_issue( - uuid, crate::issue::Issue { + id: uuid, author: String::from("sigil-03 "), timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap() @@ -250,8 +238,8 @@ mod tests { let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); expected.add_issue( - uuid, crate::issue::Issue { + id: uuid, author: String::from("sigil-03 "), timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap() From 5e5508a2ee3a0e380bdd2f4c1897826d439811f1 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 15 Jul 2025 16:27:48 -0600 Subject: [PATCH 089/123] Issue: make a helper function to commit an Issue This improves code reuse and streamlines the code a bit. --- src/issue.rs | 73 ++++++++++++++++++++++------------------------------ 1 file changed, 31 insertions(+), 42 deletions(-) diff --git a/src/issue.rs b/src/issue.rs index 0de413c..23bb59d 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -243,8 +243,7 @@ impl Issue { None => issue.edit_description_file()?, }; - crate::git::add(&issue_dir)?; - crate::git::commit(&issue_dir, &format!("create new issue {}", issue_id))?; + issue.commit(&format!("create new issue {}", issue_id))?; Ok(issue) } @@ -253,21 +252,15 @@ impl Issue { pub fn edit_description(&mut self) -> Result<(), IssueError> { 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().unwrap(), - &format!( - "edit description of issue {}", - description_filename - .parent() - .unwrap() - .file_name() - .unwrap() - .to_string_lossy() - ), - )?; - } + self.commit(&format!( + "edit description of issue {}", + description_filename + .parent() + .unwrap() + .file_name() + .unwrap() + .to_string_lossy(), + ))?; Ok(()) } @@ -286,18 +279,12 @@ impl Issue { state_filename.push("state"); let mut state_file = std::fs::File::create(&state_filename)?; write!(state_file, "{}", new_state)?; - crate::git::add(&state_filename)?; - if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { - crate::git::commit( - &self.dir, - &format!( - "change state of issue {}, {} -> {}", - self.dir.file_name().unwrap().to_string_lossy(), - old_state, - new_state, - ), - )?; - } + self.commit(&format!( + "change state of issue {}, {} -> {}", + self.dir.file_name().unwrap().to_string_lossy(), + old_state, + new_state, + ))?; Ok(()) } @@ -319,18 +306,12 @@ impl Issue { assignee_filename.push("assignee"); let mut assignee_file = std::fs::File::create(&assignee_filename)?; write!(assignee_file, "{}", new_assignee)?; - crate::git::add(&assignee_filename)?; - if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { - crate::git::commit( - &self.dir, - &format!( - "change assignee of issue {}, {} -> {}", - self.dir.file_name().unwrap().to_string_lossy(), - old_assignee, - new_assignee, - ), - )?; - } + self.commit(&format!( + "change assignee of issue {}, {} -> {}", + self.dir.file_name().unwrap().to_string_lossy(), + old_assignee, + new_assignee, + ))?; Ok(()) } @@ -444,7 +425,15 @@ impl Issue { for tag in &self.tags { writeln!(tags_file, "{}", tag)?; } - crate::git::add(&tags_filename)?; + 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(()) } From 20c17f281b656ec135af0526783ee1acd2ce5ce7 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 16 Jul 2025 21:29:42 -0600 Subject: [PATCH 090/123] `ent list FILTER`: the filter now takes multiple strings This is instead of a single big string with chunks separated by ":". ":" is used in RFC 3339 date-time strings (like "2025-07-16 21:23:44 -06:00"), so it's inconvenient to reserve ":" to be the chunk separator. I'm not super wedded to this new Vec way of doing the filter, but it seems fine and convenient for now. --- src/bin/ent/main.rs | 73 +++++++++++++++++++++++++-------------- src/lib.rs | 84 +++++++++++++++++++++++---------------------- 2 files changed, 91 insertions(+), 66 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index b211383..7499a15 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -24,22 +24,24 @@ struct Args { enum Commands { /// List issues. List { - /// Filter string, describes issues to include in the list. - /// The filter string is composed of chunks separated by ":". - /// Each chunk is of the form "name=condition". The supported - /// names and their matching conditions are: + /// 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 list. - /// Defaults to all assignees if not set. + /// "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 "-"). If omitted, defaults to including - /// all tags and excluding none. - /// - #[arg(default_value_t = String::from("state=New,Backlog,Blocked,InProgress"))] - filter: String, + /// "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. + filter: Vec, }, /// Create a new issue. @@ -93,7 +95,14 @@ fn handle_command( match &args.command { Commands::List { filter } => { let issues = entomologist::database::read_issues_database(issues_database_source)?; - let filter = entomologist::Filter::new_from_str(filter)?; + 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>, @@ -191,8 +200,10 @@ fn handle_command( } Commands::New { description } => { - let issues_database = - entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; + 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"); @@ -209,8 +220,10 @@ fn handle_command( } Commands::Edit { uuid } => { - let issues_database = - entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; + 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() { @@ -279,8 +292,10 @@ fn handle_command( new_state, } => match new_state { Some(new_state) => { - let issues_database = - entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; + 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) => { @@ -312,8 +327,10 @@ fn handle_command( issue_id, description, } => { - let issues_database = - entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; + 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)); @@ -338,9 +355,13 @@ fn handle_command( } 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)?; + 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 { @@ -440,7 +461,9 @@ fn main() -> anyhow::Result<()> { // 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)), + (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(_)) => { diff --git a/src/lib.rs b/src/lib.rs index 17104ea..dbb19e5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,12 @@ use std::str::FromStr; pub mod comment; +pub mod database; pub mod git; pub mod issue; pub mod issues; -pub mod database; + +use crate::issue::State; #[derive(Debug, thiserror::Error)] pub enum ParseFilterError { @@ -26,9 +28,8 @@ pub struct Filter<'a> { } impl<'a> Filter<'a> { - pub fn new_from_str(filter_str: &'a str) -> Result, ParseFilterError> { - use crate::issue::State; - let mut f = Filter { + pub fn new() -> Filter<'a> { + Self { include_states: std::collections::HashSet::::from([ State::InProgress, State::Blocked, @@ -38,51 +39,52 @@ impl<'a> Filter<'a> { include_assignees: std::collections::HashSet::<&'a str>::new(), include_tags: std::collections::HashSet::<&'a str>::new(), exclude_tags: std::collections::HashSet::<&'a str>::new(), - }; + } + } - for filter_chunk_str in filter_str.split(":") { - let tokens: Vec<&str> = filter_chunk_str.split("=").collect(); - if tokens.len() != 2 { - return Err(ParseFilterError::ParseError); + 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)?); + } } - match tokens[0] { - "state" => { - f.include_states.clear(); - for s in tokens[1].split(",") { - f.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); } } + } - "assignee" => { - f.include_assignees.clear(); - for s in tokens[1].split(",") { - f.include_assignees.insert(s); - } - } - - "tag" => { - f.include_tags.clear(); - f.exclude_tags.clear(); - for s in tokens[1].split(",") { - if s.len() == 0 { - return Err(ParseFilterError::ParseError); - } - if s.chars().nth(0).unwrap() == '-' { - f.exclude_tags.insert(&s[1..]); - } else { - f.include_tags.insert(s); - } - } - } - - _ => { - println!("unknown filter chunk '{}'", filter_chunk_str); - return Err(ParseFilterError::ParseError); - } + _ => { + println!("unknown filter string '{}'", filter_str); + return Err(ParseFilterError::ParseError); } } - Ok(f) + Ok(()) } } From 3df76b89df28c6eb8081732a314eaa7b5b9a8dfa Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 15 Jul 2025 15:10:34 -0600 Subject: [PATCH 091/123] rename Issue and Comment `timestamp` to `creation_time` This is to make room for a second timestamp that records when the issue was marked Done. --- src/bin/ent/main.rs | 6 +++--- src/comment.rs | 10 +++++----- src/issue.rs | 14 +++++++------- src/issues.rs | 16 ++++++++-------- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 7499a15..494b1ba 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -155,7 +155,7 @@ fn handle_command( 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.timestamp.cmp(&b.timestamp) + a.creation_time.cmp(&b.creation_time) }); println!("{:?}:", state); for uuid in these_uuids { @@ -262,7 +262,7 @@ fn handle_command( Some(issue) => { println!("issue {}", issue_id); println!("author: {}", issue.author); - println!("timestamp: {}", issue.timestamp); + println!("creation_time: {}", issue.creation_time); println!("state: {:?}", issue.state); if let Some(dependencies) = &issue.dependencies { println!("dependencies: {:?}", dependencies); @@ -276,7 +276,7 @@ fn handle_command( println!(""); println!("comment: {}", comment.uuid); println!("author: {}", comment.author); - println!("timestamp: {}", comment.timestamp); + println!("creation_time: {}", comment.creation_time); println!(""); println!("{}", comment.description); } diff --git a/src/comment.rs b/src/comment.rs index c8e26c9..216b34f 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -4,7 +4,7 @@ use std::io::{IsTerminal, Write}; pub struct Comment { pub uuid: String, pub author: String, - pub timestamp: chrono::DateTime, + pub creation_time: chrono::DateTime, pub description: String, /// This is the directory that the comment lives in. Only used @@ -53,13 +53,13 @@ impl Comment { } let author = crate::git::git_log_oldest_author(comment_dir)?; - let timestamp = crate::git::git_log_oldest_timestamp(comment_dir)?; + let creation_time = crate::git::git_log_oldest_timestamp(comment_dir)?; let dir = std::path::PathBuf::from(comment_dir); Ok(Self { uuid: String::from(dir.file_name().unwrap().to_string_lossy()), author, - timestamp, + creation_time, description: description.unwrap(), dir: std::path::PathBuf::from(comment_dir), }) @@ -84,7 +84,7 @@ impl Comment { let mut comment = crate::comment::Comment { uuid, author: String::from(""), // this will be updated from git when we re-read this comment - timestamp: chrono::Local::now(), + creation_time: chrono::Local::now(), description: String::from(""), // this will be set immediately below dir: dir.clone(), }; @@ -204,7 +204,7 @@ mod tests { let expected = Comment { uuid: String::from("9055dac36045fe36545bed7ae7b49347"), author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00") .unwrap() .with_timezone(&chrono::Local), description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), diff --git a/src/issue.rs b/src/issue.rs index 23bb59d..72c1487 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -22,7 +22,7 @@ pub type IssueHandle = String; pub struct Issue { pub id: String, pub author: String, - pub timestamp: chrono::DateTime, + pub creation_time: chrono::DateTime, pub tags: Vec, pub state: State, pub dependencies: Option>, @@ -159,12 +159,12 @@ impl Issue { }; let author = crate::git::git_log_oldest_author(dir)?; - let timestamp = crate::git::git_log_oldest_timestamp(dir)?; + let creation_time = crate::git::git_log_oldest_timestamp(dir)?; Ok(Self { id, author, - timestamp, + creation_time, tags, state: state, dependencies, @@ -185,7 +185,7 @@ impl Issue { comments.push(comment); } } - comments.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); + comments.sort_by(|a, b| a.creation_time.cmp(&b.creation_time)); Ok(()) } @@ -220,7 +220,7 @@ impl Issue { let mut issue = Self { id: String::from(&issue_id), author: String::from(""), - timestamp: chrono::Local::now(), + creation_time: chrono::Local::now(), tags: Vec::::new(), state: State::New, dependencies: None, @@ -450,7 +450,7 @@ mod tests { let expected = Issue { id: String::from("3943fc5c173fdf41c0a22251593cd476d96e6c9f"), author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), tags: Vec::::from([ @@ -477,7 +477,7 @@ mod tests { let expected = Issue { id: String::from("7792b063eef6d33e7da5dc1856750c149ba678c6"), author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), tags: Vec::::new(), diff --git a/src/issues.rs b/src/issues.rs index 42e964d..7f2e63e 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -90,7 +90,7 @@ mod tests { expected.add_issue(crate::issue::Issue { id: uuid, author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), tags: Vec::::new(), @@ -109,7 +109,7 @@ mod tests { crate::issue::Issue { id: uuid, author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), tags: Vec::::from([ @@ -141,7 +141,7 @@ mod tests { expected.add_issue(crate::issue::Issue { id: uuid, author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") .unwrap() .with_timezone(&chrono::Local), tags: Vec::::new(), @@ -165,7 +165,7 @@ mod tests { crate::comment::Comment { uuid: comment_uuid, author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00").unwrap().with_timezone(&chrono::Local), + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00").unwrap().with_timezone(&chrono::Local), description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), dir: std::path::PathBuf::from(comment_dir), } @@ -174,7 +174,7 @@ mod tests { crate::issue::Issue { id: uuid, author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") .unwrap() .with_timezone(&chrono::Local), tags: Vec::::new(), @@ -202,7 +202,7 @@ mod tests { expected.add_issue(crate::issue::Issue { id: uuid, author: String::from("sigil-03 "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap() .with_timezone(&chrono::Local), tags: Vec::::new(), @@ -221,7 +221,7 @@ mod tests { crate::issue::Issue { id: uuid, author: String::from("sigil-03 "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap() .with_timezone(&chrono::Local), tags: Vec::::new(), @@ -241,7 +241,7 @@ mod tests { crate::issue::Issue { id: uuid, author: String::from("sigil-03 "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap() .with_timezone(&chrono::Local), tags: Vec::::new(), From 3b33ed41f5463309a4f9001fae9f6d278e61aabe Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 15 Jul 2025 15:37:23 -0600 Subject: [PATCH 092/123] Issue: add `done_time` field This records the DateTime that the issue moved to the Done state (if any). --- src/issue.rs | 13 +++++++++++++ src/issues.rs | 11 +++++++++++ .../done_time | 1 + 3 files changed, 25 insertions(+) create mode 100644 test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/done_time diff --git a/src/issue.rs b/src/issue.rs index 72c1487..927eb7f 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -23,6 +23,7 @@ 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>, @@ -43,6 +44,8 @@ pub enum IssueError { 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")] @@ -106,6 +109,7 @@ impl Issue { 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 { @@ -119,6 +123,11 @@ impl Issue { 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" { let dep_strings = std::fs::read_to_string(direntry.path())?; let deps: Vec = dep_strings @@ -165,6 +174,7 @@ impl Issue { id, author, creation_time, + done_time, tags, state: state, dependencies, @@ -221,6 +231,7 @@ impl Issue { id: String::from(&issue_id), author: String::from(""), creation_time: chrono::Local::now(), + done_time: None, tags: Vec::::new(), state: State::New, dependencies: None, @@ -453,6 +464,7 @@ mod tests { creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), + done_time: None, tags: Vec::::from([ String::from("tag1"), String::from("TAG2"), @@ -480,6 +492,7 @@ mod tests { creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), + done_time: None, tags: Vec::::new(), state: State::InProgress, dependencies: None, diff --git a/src/issues.rs b/src/issues.rs index 7f2e63e..709a79d 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -93,6 +93,7 @@ mod tests { creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), + done_time: None, tags: Vec::::new(), state: crate::issue::State::InProgress, dependencies: None, @@ -112,6 +113,7 @@ mod tests { creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), + done_time: None, tags: Vec::::from([ String::from("tag1"), String::from("TAG2"), @@ -144,6 +146,11 @@ mod tests { creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-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, @@ -177,6 +184,7 @@ mod tests { creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") .unwrap() .with_timezone(&chrono::Local), + done_time: None, tags: Vec::::new(), state: crate::issue::State::WontDo, dependencies: None, @@ -205,6 +213,7 @@ mod tests { creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap() .with_timezone(&chrono::Local), + done_time: None, tags: Vec::::new(), state: crate::issue::State::Done, dependencies: None, @@ -224,6 +233,7 @@ mod tests { creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap() .with_timezone(&chrono::Local), + done_time: None, tags: Vec::::new(), state: crate::issue::State::WontDo, dependencies: None, @@ -244,6 +254,7 @@ mod tests { creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap() .with_timezone(&chrono::Local), + done_time: None, tags: Vec::::new(), state: crate::issue::State::WontDo, dependencies: Some(vec![ diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/done_time b/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/done_time new file mode 100644 index 0000000..d455c4d --- /dev/null +++ b/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/done_time @@ -0,0 +1 @@ +2025-07-15T15:15:15-06:00 From bc2b1bd3c15aa6c8a511904bb1effcdcafc4f961 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 15 Jul 2025 16:28:14 -0600 Subject: [PATCH 093/123] add API and CLI to get & set done-time of an issue --- src/bin/ent/main.rs | 41 +++++++++++++++++++++++++++++++++++++++++ src/issue.rs | 24 +++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 494b1ba..ac55045 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -86,6 +86,12 @@ enum Commands { #[arg(allow_hyphen_values = true)] tag: Option, }, + + /// Get or set the `done_time` of the Issue. + DoneTime { + issue_id: String, + done_time: Option, + }, } fn handle_command( @@ -263,6 +269,9 @@ fn handle_command( println!("issue {}", issue_id); println!("author: {}", issue.author); 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); @@ -448,6 +457,38 @@ fn handle_command( } } } + + Commands::DoneTime { + issue_id, + done_time, + } => { + 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 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 = chrono::DateTime::parse_from_rfc3339(done_time) + .unwrap() + .with_timezone(&chrono::Local); + issue.set_done_time(done_time)?; + } + None => match &issue.done_time { + Some(done_time) => println!("done_time: {}", done_time), + None => println!("None"), + }, + }; + } } Ok(()) diff --git a/src/issue.rs b/src/issue.rs index 927eb7f..bc3d959 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -283,7 +283,8 @@ impl Issue { } } - /// Change the State of the Issue. + /// 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); @@ -296,6 +297,9 @@ impl Issue { old_state, new_state, ))?; + if new_state == State::Done { + self.set_done_time(chrono::Local::now())?; + } Ok(()) } @@ -307,6 +311,24 @@ impl Issue { 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().unwrap().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 { From a3077ca31321757772a6d6d1f283dca0c4cdb40e Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 16 Jul 2025 21:28:15 -0600 Subject: [PATCH 094/123] `ent list FILTER`: add filter "done-time=[START]..[END]" --- src/bin/ent/main.rs | 20 ++++++++++++++++++++ src/lib.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index ac55045..a597fd9 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -41,6 +41,13 @@ enum Commands { /// 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, }, @@ -138,6 +145,19 @@ fn handle_command( } } + 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()) diff --git a/src/lib.rs b/src/lib.rs index dbb19e5..b6245b9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,8 @@ pub enum ParseFilterError { 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 @@ -25,6 +27,8 @@ pub struct Filter<'a> { 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> { @@ -39,6 +43,8 @@ impl<'a> Filter<'a> { 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, } } @@ -79,6 +85,27 @@ impl<'a> Filter<'a> { } } + "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); From 3e0ab7092e5afa4367c5349eb76a118b9ae3962e Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Tue, 15 Jul 2025 10:56:09 -0600 Subject: [PATCH 095/123] update CLI to print the issue ID when a new issue is created --- src/bin/ent/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index b211383..fff83e1 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -203,6 +203,7 @@ fn handle_command( } Ok(issue) => { println!("created new issue '{}'", issue.title()); + println!("ID: {}", issue.id); return Ok(()); } } From 8319a4f118d5735c33e377b71d7cb689d2cdffb8 Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Fri, 18 Jul 2025 16:20:17 -0600 Subject: [PATCH 096/123] add dependency API / fix dependency representation / dependency management via CLI --- src/bin/ent/main.rs | 45 ++++++++++++++++++++++++++- src/issue.rs | 74 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 1698954..4118ed3 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -99,6 +99,12 @@ enum Commands { issue_id: String, done_time: Option, }, + + /// get or add a dependency to the issue + Depend { + issue_id: String, + dependency_id: Option, + }, } fn handle_command( @@ -510,6 +516,43 @@ fn handle_command( }, }; } + + 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(()) @@ -531,7 +574,7 @@ fn main() -> anyhow::Result<()> { (Some(_), Some(_)) => { return Err(anyhow::anyhow!( "don't specify both `--issues-dir` and `--issues-branch`" - )) + )); } }; diff --git a/src/issue.rs b/src/issue.rs index bc3d959..363e2b7 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -62,6 +62,12 @@ pub enum IssueError { 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 { @@ -128,15 +134,8 @@ impl Issue { std::fs::read_to_string(direntry.path())?.trim(), )?; done_time = Some(raw_done_time.into()); - } else if file_name == "dependencies" { - let dep_strings = std::fs::read_to_string(direntry.path())?; - let deps: Vec = dep_strings - .lines() - .map(|dep| IssueHandle::from(dep)) - .collect(); - if deps.len() > 0 { - dependencies = Some(deps); - } + } 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 @@ -199,6 +198,23 @@ impl Issue { 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()]); + } + } + } + } + Ok(dependencies) + } + /// Add a new Comment to the Issue. Commits. pub fn add_comment( &mut self, @@ -392,6 +408,46 @@ impl Issue { } 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. From 2ba13ebaeb27189fddfb44f897242cd577d9d33e Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 19 Jul 2025 09:53:36 -0600 Subject: [PATCH 097/123] Issue: get rid of all unwraps Make and return errors instead. --- src/issue.rs | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/issue.rs b/src/issue.rs index bc3d959..e129dff 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -153,9 +153,9 @@ impl Issue { } } - if description == None { + 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() { @@ -179,7 +179,7 @@ impl Issue { state: state, dependencies, assignee, - description: description.unwrap(), + description, comments, dir: std::path::PathBuf::from(dir), }) @@ -267,9 +267,9 @@ impl Issue { "edit description of issue {}", description_filename .parent() - .unwrap() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? .file_name() - .unwrap() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? .to_string_lossy(), ))?; Ok(()) @@ -293,7 +293,10 @@ impl Issue { write!(state_file, "{}", new_state)?; self.commit(&format!( "change state of issue {}, {} -> {}", - self.dir.file_name().unwrap().to_string_lossy(), + self.dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), old_state, new_state, ))?; @@ -323,7 +326,10 @@ impl Issue { self.done_time = Some(done_time.clone()); self.commit(&format!( "set done-time of issue {} to {}", - self.dir.file_name().unwrap().to_string_lossy(), + self.dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), done_time, ))?; Ok(()) @@ -341,7 +347,10 @@ impl Issue { write!(assignee_file, "{}", new_assignee)?; self.commit(&format!( "change assignee of issue {}, {} -> {}", - self.dir.file_name().unwrap().to_string_lossy(), + self.dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), old_assignee, new_assignee, ))?; @@ -358,7 +367,10 @@ impl Issue { self.tags.sort(); self.commit_tags(&format!( "issue {} add tag {}", - self.dir.file_name().unwrap().to_string_lossy(), + self.dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), tag ))?; Ok(()) @@ -373,7 +385,10 @@ impl Issue { self.tags.remove(index); self.commit_tags(&format!( "issue {} remove tag {}", - self.dir.file_name().unwrap().to_string_lossy(), + self.dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), tag ))?; Ok(()) @@ -432,8 +447,8 @@ impl Issue { .spawn()? .wait_with_output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + 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 { From 97a575316e4af91300b6c0414e5f3160578b3691 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 19 Jul 2025 09:54:00 -0600 Subject: [PATCH 098/123] Issues: skip & warn about any Issue that fails to parse This lets us at least handle the other, valid issues, while informing the user about the ones we don't understand. --- src/issues.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/issues.rs b/src/issues.rs index 709a79d..fe5728c 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -56,8 +56,19 @@ impl Issues { for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { if direntry.metadata()?.is_dir() { - let issue = crate::issue::Issue::new_from_dir(direntry.path().as_path())?; - issues.add_issue(issue); + match crate::issue::Issue::new_from_dir(direntry.path().as_path()) { + Err(e) => { + println!( + "failed to parse issue {}, skipping", + direntry.file_name().to_string_lossy() + ); + println!("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 { From c2174340711ad4628dea5b8899c93d48cb5c406f Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 19 Jul 2025 10:38:15 -0600 Subject: [PATCH 099/123] better error handling in comment and git This replaces a bunch of `unwrap()` calls with error returns. --- src/comment.rs | 31 ++++++++---- src/git.rs | 125 +++++++++++++++++++++++++++++-------------------- 2 files changed, 96 insertions(+), 60 deletions(-) diff --git a/src/comment.rs b/src/comment.rs index 216b34f..17324b3 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -48,19 +48,23 @@ impl Comment { } } } - if description == None { + let Some(description) = description else { return Err(CommentError::CommentParseError); - } + }; let author = crate::git::git_log_oldest_author(comment_dir)?; let creation_time = crate::git::git_log_oldest_timestamp(comment_dir)?; let dir = std::path::PathBuf::from(comment_dir); Ok(Self { - uuid: String::from(dir.file_name().unwrap().to_string_lossy()), + uuid: String::from( + dir.file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), + ), author, creation_time, - description: description.unwrap(), + description, dir: std::path::PathBuf::from(comment_dir), }) } @@ -109,7 +113,11 @@ impl Comment { &format!( "add comment {} on issue {}", comment.uuid, - issue.dir.file_name().unwrap().to_string_lossy(), + issue + .dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), ), )?; } @@ -130,10 +138,15 @@ impl Comment { crate::git::add(&description_filename)?; if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { crate::git::commit( - &description_filename.parent().unwrap(), + &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().unwrap().to_string_lossy() + self.dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy() ), )?; self.read_description()?; @@ -165,8 +178,8 @@ impl Comment { .spawn()? .wait_with_output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(CommentError::EditorError); } diff --git a/src/git.rs b/src/git.rs index fe0446a..4a50d57 100644 --- a/src/git.rs +++ b/src/git.rs @@ -48,8 +48,8 @@ impl Worktree { .args(["worktree", "add", &path.path().to_string_lossy(), branch]) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(Self { path }) @@ -67,8 +67,8 @@ impl Worktree { ]) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(Self { path }) @@ -87,8 +87,8 @@ pub fn checkout_branch_in_worktree( .args(["worktree", "add", &worktree_dir.to_string_lossy(), branch]) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(()) @@ -99,8 +99,8 @@ pub fn git_worktree_prune() -> Result<(), GitError> { .args(["worktree", "prune"]) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(()) @@ -111,8 +111,8 @@ pub fn git_remove_branch(branch: &str) -> Result<(), GitError> { .args(["branch", "-D", branch]) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(()) @@ -139,11 +139,14 @@ pub fn worktree_is_dirty(dir: &str) -> Result { 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().unwrap()) + .current_dir( + file.parent() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?, + ) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } return Ok(()); @@ -152,11 +155,14 @@ pub fn add(file: &std::path::Path) -> Result<(), GitError> { 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().unwrap()) + .current_dir( + file.parent() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?, + ) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } return Ok(()); @@ -168,8 +174,8 @@ pub fn commit(dir: &std::path::Path, msg: &str) -> Result<(), GitError> { .current_dir(dir) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(()) @@ -180,12 +186,18 @@ pub fn git_commit_file(file: &std::path::Path) -> Result<(), GitError> { git_dir.pop(); let result = std::process::Command::new("git") - .args(["add", &file.file_name().unwrap().to_string_lossy()]) + .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: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } @@ -195,15 +207,20 @@ pub fn git_commit_file(file: &std::path::Path) -> Result<(), GitError> { "-m", &format!( "update '{}' in issue {}", - file.file_name().unwrap().to_string_lossy(), - git_dir.file_name().unwrap().to_string_lossy() + 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: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } @@ -216,8 +233,8 @@ pub fn git_fetch(dir: &std::path::Path, remote: &str) -> Result<(), GitError> { .current_dir(dir) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(()) @@ -253,13 +270,13 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}", branch ); - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + 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!("{}", std::str::from_utf8(&result.stdout).unwrap()); + println!("{}", &String::from_utf8_lossy(&result.stdout)); println!(""); } @@ -279,13 +296,13 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}", branch ); - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + 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!("{}", std::str::from_utf8(&result.stdout).unwrap()); + println!("{}", &String::from_utf8_lossy(&result.stdout)); println!(""); } @@ -299,8 +316,8 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git "Sync failed! Merge error! Help, a human needs to fix the mess in {:?}", branch ); - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } @@ -314,8 +331,8 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git "Sync failed! Push error! Help, a human needs to fix the mess in {:?}", branch ); - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } @@ -332,13 +349,16 @@ pub fn git_log_oldest_timestamp( "log", "--pretty=format:%at", "--", - &path.file_name().unwrap().to_string_lossy(), + &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: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + 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(); @@ -358,13 +378,16 @@ pub fn git_log_oldest_author(path: &std::path::Path) -> Result "log", "--pretty=format:%an <%ae>", "--", - &path.file_name().unwrap().to_string_lossy(), + &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: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + 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(); @@ -383,8 +406,8 @@ pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> { .args(["worktree", "prune"]) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } @@ -400,8 +423,8 @@ fn create_orphan_branch_at_path( .args(["worktree", "add", "--orphan", "-b", branch, &worktree_dir]) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } @@ -418,8 +441,8 @@ fn create_orphan_branch_at_path( .current_dir(worktree_path) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } @@ -428,8 +451,8 @@ fn create_orphan_branch_at_path( .current_dir(worktree_path) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } From e79fc4917d703b71a9f04431bf1b667079af0aef Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 19 Jul 2025 10:52:33 -0600 Subject: [PATCH 100/123] Issues::new_from_dir(): move error message to stderr --- src/issues.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/issues.rs b/src/issues.rs index fe5728c..a01f41c 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -58,11 +58,11 @@ impl Issues { if direntry.metadata()?.is_dir() { match crate::issue::Issue::new_from_dir(direntry.path().as_path()) { Err(e) => { - println!( + eprintln!( "failed to parse issue {}, skipping", direntry.file_name().to_string_lossy() ); - println!("ignoring error: {:?}", e); + eprintln!("ignoring error: {:?}", e); continue; } Ok(issue) => { From 8b41f1ebc67a33a3fd6a8cc6c8522bd2a82ece70 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 19 Jul 2025 13:15:50 -0600 Subject: [PATCH 101/123] add a tools directory including a "done-last-week" script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This script lists issues that were marked `Done` between "midnight at the start of the second most recent Monday" and "midnight at the start of the most recent Monday". $ ./tools/done-last-week Done: 8c73c9fd5bc4f551ee5069035ae6e866 migrate the Todo list into entomologist 75cefad80aacbf23fc7b9c24a75aa236 🗨️ 4 # implement `ent comment ISSUE [DESCRIPTION]` (👉 seb) 7da3bd5b72de0a05936b094db5d24304 🗨️ 1 implement `ent edit ${COMMENT}` (👉 seb) 198a7d56a19f0579fbc04f2ee9cc234f fix ignoring unknown file in issues directory: "README.md" e089400e8a9e11fe9bf10d50b2f889d7 add `ent sync` to keep local `entomologist-data` branch in sync with remote a26da230276d317e85f9fcca41c19d2e `ent edit ${ISSUE}` with no change fails (👉 seb) 317ea8ccac1d414cde55771321bdec30 🗨️ 2 allow multiple read-only ent processes simultaneously (👉 seb) da435e5e298b28dc223f9dcfe62a9140 add user control over state transitions (👉 lex) fd81241f795333b64e7911cfb1b57c8f commit messages in the `entomologist-data` branch could be better (👉 seb) 093e87e8049b93bfa2d8fcd544cae75f add optional 'assignee' to issue (👉 seb) 793bda8b9726b0336d97e856895907f8 `ent list` should have a consistent sort order (👉 seb) af53c561b36e9b2709b939f81daee534 use git author info to attribute issues and comments to people (👉 seb) 9e69a30ad6965d7488514584c97ac63c teach `ent list FILTER` to filter by assignee (👉 seb) a5ac277614ea4d13f78031abb25ea7d6 `ent new` and `ent comment`: detect empty issue descriptions & comments (👉 seb) 7d2d236668872cf11f167ac0462f8751 🗨️ 1 add `ent tag ISSUE [[-]TAG]` (👉 seb) 54f0eb67b05aa10763c86869ce840f33 `ent sync` should report what changes got fetched & what changes will be pushed (👉 seb) 4e314a8590864fa76d22758e1785ae35 don't spawn an editor if stdin & stdout aren't a terminal (👉 seb) d3a705245bd69aa56524b80b5ae0bc26 🗨️ 1 move IssuesDatabase out of binary and into library (👉 sigil-03) --- tools/README.md | 4 ++++ tools/done-last-week | 11 +++++++++++ tools/set-done-time | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 tools/README.md create mode 100755 tools/done-last-week create mode 100755 tools/set-done-time diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..dd3cb2f --- /dev/null +++ b/tools/README.md @@ -0,0 +1,4 @@ +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 new file mode 100755 index 0000000..da9ec94 --- /dev/null +++ b/tools/done-last-week @@ -0,0 +1,11 @@ +#!/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 new file mode 100755 index 0000000..6a29fd9 --- /dev/null +++ b/tools/set-done-time @@ -0,0 +1,19 @@ +#!/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 From 8af9c71ef6953471db44acdd56bd707bb85ff4b7 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sat, 19 Jul 2025 21:10:14 -0600 Subject: [PATCH 102/123] `ent done-time ISSUE TIME`: report parse error instead of panicking --- src/bin/ent/main.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 1698954..651e0fc 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -499,9 +499,13 @@ fn handle_command( let Some(issue) = issues.get_mut_issue(issue_id) else { return Err(anyhow::anyhow!("issue {} not found", issue_id)); }; - let done_time = chrono::DateTime::parse_from_rfc3339(done_time) - .unwrap() - .with_timezone(&chrono::Local); + 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 => match &issue.done_time { From 0d9a893087b50e66a05def0d4f4f6d9f9054975e Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 20 Jul 2025 00:01:19 -0600 Subject: [PATCH 103/123] `ent show`: simplify logic This simplifies the code flow and gets rid of two levels of indentation. --- src/bin/ent/main.rs | 54 +++++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 1698954..c9327e1 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -285,35 +285,31 @@ fn handle_command( Commands::Show { issue_id } => { let issues = entomologist::database::read_issues_database(issues_database_source)?; - match issues.get_issue(issue_id) { - Some(issue) => { - println!("issue {}", issue_id); - println!("author: {}", issue.author); - 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); - } - } - None => { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); - } + 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); + 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); } } From c9dbec730cf18c5175a38c84b78443c204a0206f Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 20 Jul 2025 00:04:11 -0600 Subject: [PATCH 104/123] `ent show`: show tags, if any --- src/bin/ent/main.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index c9327e1..13550db 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -290,6 +290,15 @@ fn handle_command( }; 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); From 8a92bf2637e26536bb7fef7aa1920ed36b7ffc98 Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Sun, 20 Jul 2025 11:55:29 -0600 Subject: [PATCH 105/123] fix test directory to match updated dependency representation --- test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies | 2 -- .../dependencies/3fa5bfd93317ad25772680071d5ac3259cd2384f | 0 .../dependencies/dd79c8cfb8beeacd0460429944b4ecbe95a31561 | 0 3 files changed, 2 deletions(-) delete mode 100644 test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies create mode 100644 test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies/3fa5bfd93317ad25772680071d5ac3259cd2384f create mode 100644 test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies/dd79c8cfb8beeacd0460429944b4ecbe95a31561 diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies deleted file mode 100644 index 71e4ee3..0000000 --- a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies +++ /dev/null @@ -1,2 +0,0 @@ -3fa5bfd93317ad25772680071d5ac3259cd2384f -dd79c8cfb8beeacd0460429944b4ecbe95a31561 \ No newline at end of file diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies/3fa5bfd93317ad25772680071d5ac3259cd2384f b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies/3fa5bfd93317ad25772680071d5ac3259cd2384f new file mode 100644 index 0000000..e69de29 diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies/dd79c8cfb8beeacd0460429944b4ecbe95a31561 b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies/dd79c8cfb8beeacd0460429944b4ecbe95a31561 new file mode 100644 index 0000000..e69de29 From 9b8c077653861b9507e3b5dd75272879ceac318c Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 20 Jul 2025 11:58:47 -0600 Subject: [PATCH 106/123] remove Todo file, we have entomologist-data now --- Todo.md | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 Todo.md diff --git a/Todo.md b/Todo.md deleted file mode 100644 index 9594c71..0000000 --- a/Todo.md +++ /dev/null @@ -1,19 +0,0 @@ -# To do - -* migrate this todo list into entomologist - -* implement user control over state transitions - -* 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? - -* implement `ent edit ${COMMENT}` - -* 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 - -* write a manpage From f60dd18c0afd7132a459a18cccbab8f6a32ea2de Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 20 Jul 2025 22:22:41 -0600 Subject: [PATCH 107/123] sort dependencies alphabetically after reading them This is mostly to make the tests reliable. Without this the dependencies are inserted into the vector in directory order, which in my checkout of the repo did not match the alphabetical order of the dependencies in the test. --- src/issue.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/issue.rs b/src/issue.rs index 5022ace..aafb4e2 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -212,6 +212,9 @@ impl Issue { } } } + if let Some(deps) = &mut dependencies { + deps.sort(); + } Ok(dependencies) } From 452671d272e049263030cacc9ea1afba56beb926 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 22 Jul 2025 10:28:29 -0600 Subject: [PATCH 108/123] add `time-ent` tool to measure runtime of different ent commands --- tools/time-ent | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100755 tools/time-ent diff --git a/tools/time-ent b/tools/time-ent new file mode 100755 index 0000000..366a02c --- /dev/null +++ b/tools/time-ent @@ -0,0 +1,43 @@ +#!/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}" From cc1b3783468a9d29aad2f620105deed53a967a83 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 22 Jul 2025 10:01:31 -0600 Subject: [PATCH 109/123] ent tag: speed up adding/removing tag --- src/bin/ent/main.rs | 75 ++++++++++++++++++++++----------------------- 1 file changed, 36 insertions(+), 39 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 549d16d..d3975c3 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -445,50 +445,47 @@ fn handle_command( } } - Commands::Tag { issue_id, tag } => { - 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 tag { - Some(tag) => { - // 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)); - }; - if tag.len() == 0 { - return Err(anyhow::anyhow!("invalid zero-length tag")); - } - if tag.chars().nth(0).unwrap() == '-' { - let tag = &tag[1..]; - issue.remove_tag(tag)?; - } else { - issue.add_tag(tag)?; - } + 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")); } - None => { - // Just list the tags. - match &issue.tags.len() { - 0 => println!("no tags"), - _ => { - // Could use `format!(" {:?}", issue.tags)` - // here, but that results in `["tag1", "TAG2", - // "i-am-also-a-tag"]` and i don't want the - // double-quotes around each tag. - for tag in &issue.tags { - println!("{}", tag); - } + 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, From e2a7c81a132607b1430a32d206d3c6c59a2502a0 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 22 Jul 2025 10:08:12 -0600 Subject: [PATCH 110/123] ent assign: speed up setting of assignee --- src/bin/ent/main.rs | 58 +++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index d3975c3..8a947e5 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -415,35 +415,37 @@ fn handle_command( Commands::Assign { issue_id, new_assignee, - } => { - 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); - 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)); - }; - println!("assignee: {} -> {}", old_assignee, new_assignee); - issue.set_assignee(new_assignee)?; - } - None => { - println!("assignee: {}", old_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) => { From def729d43a761d07a1a3851cdb87ca663a167a2a Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 22 Jul 2025 10:12:09 -0600 Subject: [PATCH 111/123] ent done-time: speed up setting of done-time --- src/bin/ent/main.rs | 59 ++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 8a947e5..e071233 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -492,38 +492,37 @@ fn handle_command( Commands::DoneTime { issue_id, done_time, - } => { - 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 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 => match &issue.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, From c15736259c445311120bbb52e24175e745f34d16 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 22 Jul 2025 13:04:54 -0600 Subject: [PATCH 112/123] add git::git_log_oldest_author_timestamp(), saves us one `git log` This cuts about 30% off the time to read the issues from entomologist-data. --- src/git.rs | 40 ++++++++++++++++++++++++++++++++++++++++ src/issue.rs | 3 +-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/git.rs b/src/git.rs index 4a50d57..3a03bac 100644 --- a/src/git.rs +++ b/src/git.rs @@ -395,6 +395,46 @@ pub fn git_log_oldest_author(path: &std::path::Path) -> Result 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(); diff --git a/src/issue.rs b/src/issue.rs index aafb4e2..e3bf9d3 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -166,8 +166,7 @@ impl Issue { Err(IssueError::IdError)? }; - let author = crate::git::git_log_oldest_author(dir)?; - let creation_time = crate::git::git_log_oldest_timestamp(dir)?; + let (author, creation_time) = crate::git::git_log_oldest_author_timestamp(dir)?; Ok(Self { id, From eb7ac21ac85feae7b28861cb4ecf76b84b396d58 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Tue, 22 Jul 2025 13:13:59 -0600 Subject: [PATCH 113/123] half as many `git log` calls when reading a comment --- src/comment.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/comment.rs b/src/comment.rs index 17324b3..e042f63 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -52,8 +52,8 @@ impl Comment { return Err(CommentError::CommentParseError); }; - let author = crate::git::git_log_oldest_author(comment_dir)?; - let creation_time = crate::git::git_log_oldest_timestamp(comment_dir)?; + let (author, creation_time) = crate::git::git_log_oldest_author_timestamp(comment_dir)?; + let dir = std::path::PathBuf::from(comment_dir); Ok(Self { From e1287514f65fcc8052a4627170dc2c8018a8a353 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 23 Jul 2025 15:26:42 -0600 Subject: [PATCH 114/123] switch to pretty_assertions, makes it much easier to tell what blew up --- Cargo.toml | 3 +++ src/comment.rs | 1 + src/git.rs | 1 + src/issue.rs | 1 + src/issues.rs | 1 + 5 files changed, 7 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 864691a..4d2d2c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,9 @@ edition = "2024" default = [] log = ["dep:log", "dep:simple_logger"] +[dev-dependencies] +pretty_assertions = "1.4.1" + [dependencies] anyhow = "1.0.95" chrono = "0.4.41" diff --git a/src/comment.rs b/src/comment.rs index e042f63..9770d65 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -208,6 +208,7 @@ impl Comment { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn read_comment_0() { diff --git a/src/git.rs b/src/git.rs index 3a03bac..6e70fa8 100644 --- a/src/git.rs +++ b/src/git.rs @@ -502,6 +502,7 @@ fn create_orphan_branch_at_path( #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn test_worktree() { diff --git a/src/issue.rs b/src/issue.rs index e3bf9d3..4d82deb 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -548,6 +548,7 @@ impl Issue { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn read_issue_0() { diff --git a/src/issues.rs b/src/issues.rs index a01f41c..7c43e43 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -87,6 +87,7 @@ impl Issues { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn read_issues_0000() { From 7abcf2e4466d95464252c036743cda3a757726a9 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 23 Jul 2025 18:45:20 -0600 Subject: [PATCH 115/123] sort issue tags This will be useful testing (and general consistency) when tags are files in a directory instead of lines in a file, and thus subject to random directory order. --- src/issue.rs | 3 ++- src/issues.rs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/issue.rs b/src/issue.rs index 4d82deb..6f5889e 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -143,6 +143,7 @@ impl Issue { .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 { @@ -562,9 +563,9 @@ mod tests { .with_timezone(&chrono::Local), done_time: None, tags: Vec::::from([ - String::from("tag1"), String::from("TAG2"), String::from("i-am-also-a-tag"), + String::from("tag1"), ]), state: State::New, dependencies: None, diff --git a/src/issues.rs b/src/issues.rs index 7c43e43..fc182e7 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -127,9 +127,9 @@ mod tests { .with_timezone(&chrono::Local), done_time: None, tags: Vec::::from([ - String::from("tag1"), String::from("TAG2"), - String::from("i-am-also-a-tag") + String::from("i-am-also-a-tag"), + String::from("tag1"), ]), state: crate::issue::State::New, dependencies: None, From ef8a648cf8d6f60ac82c0017bec930459e3a76f3 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 24 Jul 2025 08:36:25 -0600 Subject: [PATCH 116/123] test dir cleanup: rename test/0000/3943fc5c173fdf41c0a22251593cd476 Renaming everything also means they have new creation-times, since we're now git logging a different file/dir. --- src/issue.rs | 6 +++--- src/issues.rs | 4 ++-- .../description | 0 .../tags | 0 4 files changed, 5 insertions(+), 5 deletions(-) rename test/0000/{3943fc5c173fdf41c0a22251593cd476d96e6c9f => 3943fc5c173fdf41c0a22251593cd476}/description (100%) rename test/0000/{3943fc5c173fdf41c0a22251593cd476d96e6c9f => 3943fc5c173fdf41c0a22251593cd476}/tags (100%) diff --git a/src/issue.rs b/src/issue.rs index 6f5889e..3e13a7d 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -553,12 +553,12 @@ mod tests { #[test] fn read_issue_0() { - let issue_dir = std::path::Path::new("test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/"); + 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("3943fc5c173fdf41c0a22251593cd476d96e6c9f"), + id: String::from("3943fc5c173fdf41c0a22251593cd476"), author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:36:25-06:00") .unwrap() .with_timezone(&chrono::Local), done_time: None, diff --git a/src/issues.rs b/src/issues.rs index fc182e7..11cb233 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -115,14 +115,14 @@ mod tests { dir, }); - let uuid = String::from("3943fc5c173fdf41c0a22251593cd476d96e6c9f"); + 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-03T12:14:26-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:36:25-06:00") .unwrap() .with_timezone(&chrono::Local), done_time: None, diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description b/test/0000/3943fc5c173fdf41c0a22251593cd476/description similarity index 100% rename from test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description rename to test/0000/3943fc5c173fdf41c0a22251593cd476/description diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/tags b/test/0000/3943fc5c173fdf41c0a22251593cd476/tags similarity index 100% rename from test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/tags rename to test/0000/3943fc5c173fdf41c0a22251593cd476/tags From 4683760942d33f99c50e321c3a901c0fc619da96 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 24 Jul 2025 08:37:07 -0600 Subject: [PATCH 117/123] test dir cleanup: rename test/0000/7792b063eef6d33e7da5dc1856750c14 Renaming everything also means they have new creation-times, since we're now git logging a different file/dir. --- src/issue.rs | 6 +++--- src/issues.rs | 4 ++-- .../assignee | 0 .../description | 0 .../state | 0 5 files changed, 5 insertions(+), 5 deletions(-) rename test/0000/{7792b063eef6d33e7da5dc1856750c149ba678c6 => 7792b063eef6d33e7da5dc1856750c14}/assignee (100%) rename test/0000/{7792b063eef6d33e7da5dc1856750c149ba678c6 => 7792b063eef6d33e7da5dc1856750c14}/description (100%) rename test/0000/{7792b063eef6d33e7da5dc1856750c149ba678c6 => 7792b063eef6d33e7da5dc1856750c14}/state (100%) diff --git a/src/issue.rs b/src/issue.rs index 3e13a7d..06f959f 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -581,12 +581,12 @@ mod tests { #[test] fn read_issue_1() { - let issue_dir = std::path::Path::new("test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/"); + 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("7792b063eef6d33e7da5dc1856750c149ba678c6"), + id: String::from("7792b063eef6d33e7da5dc1856750c14"), author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:37:07-06:00") .unwrap() .with_timezone(&chrono::Local), done_time: None, diff --git a/src/issues.rs b/src/issues.rs index 11cb233..db32737 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -96,13 +96,13 @@ mod tests { let mut expected = Issues::new(); - let uuid = String::from("7792b063eef6d33e7da5dc1856750c149ba678c6"); + 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-03T12:14:26-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:37:07-06:00") .unwrap() .with_timezone(&chrono::Local), done_time: None, diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/assignee b/test/0000/7792b063eef6d33e7da5dc1856750c14/assignee similarity index 100% rename from test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/assignee rename to test/0000/7792b063eef6d33e7da5dc1856750c14/assignee diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/description b/test/0000/7792b063eef6d33e7da5dc1856750c14/description similarity index 100% rename from test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/description rename to test/0000/7792b063eef6d33e7da5dc1856750c14/description diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/state b/test/0000/7792b063eef6d33e7da5dc1856750c14/state similarity index 100% rename from test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/state rename to test/0000/7792b063eef6d33e7da5dc1856750c14/state From 694d127638842de94ef646819be71e1e1bb86255 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 24 Jul 2025 08:37:46 -0600 Subject: [PATCH 118/123] test dir cleanup: rename test/0001/3fa5bfd93317ad25772680071d5ac325 Renaming everything also means they have new creation-times, since we're now git logging a different file/dir. --- src/issues.rs | 4 ++-- .../description | 0 .../done_time | 0 .../state | 0 4 files changed, 2 insertions(+), 2 deletions(-) rename test/0001/{3fa5bfd93317ad25772680071d5ac3259cd2384f => 3fa5bfd93317ad25772680071d5ac325}/description (100%) rename test/0001/{3fa5bfd93317ad25772680071d5ac3259cd2384f => 3fa5bfd93317ad25772680071d5ac325}/done_time (100%) rename test/0001/{3fa5bfd93317ad25772680071d5ac3259cd2384f => 3fa5bfd93317ad25772680071d5ac325}/state (100%) diff --git a/src/issues.rs b/src/issues.rs index db32737..bd8d687 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -149,13 +149,13 @@ mod tests { let mut expected = Issues::new(); - let uuid = String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"); + 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-03T11:59:44-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:37:46-06:00") .unwrap() .with_timezone(&chrono::Local), done_time: Some( diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/description b/test/0001/3fa5bfd93317ad25772680071d5ac325/description similarity index 100% rename from test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/description rename to test/0001/3fa5bfd93317ad25772680071d5ac325/description diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/done_time b/test/0001/3fa5bfd93317ad25772680071d5ac325/done_time similarity index 100% rename from test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/done_time rename to test/0001/3fa5bfd93317ad25772680071d5ac325/done_time diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/state b/test/0001/3fa5bfd93317ad25772680071d5ac325/state similarity index 100% rename from test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/state rename to test/0001/3fa5bfd93317ad25772680071d5ac325/state From 05c7c6f4416f23d8d71b54e80ca2a0683bad2b2c Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 24 Jul 2025 10:08:24 -0600 Subject: [PATCH 119/123] test dir cleanup: rename test/0001/dd79c8cfb8beeacd0460429944b4ecbe Renaming everything also means they have new creation-times, since we're now git logging a different file/dir. fixup test/0001/dd79c8cfb8beeacd0460429944b4ecbe, no comment yet --- src/issues.rs | 15 +++------------ .../description | 0 .../state | 0 3 files changed, 3 insertions(+), 12 deletions(-) rename test/0001/{dd79c8cfb8beeacd0460429944b4ecbe95a31561 => dd79c8cfb8beeacd0460429944b4ecbe}/description (100%) rename test/0001/{dd79c8cfb8beeacd0460429944b4ecbe95a31561 => dd79c8cfb8beeacd0460429944b4ecbe}/state (100%) diff --git a/src/issues.rs b/src/issues.rs index bd8d687..e8b759f 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -172,28 +172,19 @@ mod tests { dir, }); - let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"); + 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-07T15:26:26-06:00").unwrap().with_timezone(&chrono::Local), - description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), - dir: std::path::PathBuf::from(comment_dir), - } - ); + let expected_comments = Vec::::new(); expected.add_issue( crate::issue::Issue { id: uuid, author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T10:08:24-06:00") .unwrap() .with_timezone(&chrono::Local), done_time: None, diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/description similarity index 100% rename from test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description rename to test/0001/dd79c8cfb8beeacd0460429944b4ecbe/description diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/state similarity index 100% rename from test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state rename to test/0001/dd79c8cfb8beeacd0460429944b4ecbe/state From 598f4e5df838618584ca81e1edceb9d6791b228e Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Thu, 24 Jul 2025 10:08:38 -0600 Subject: [PATCH 120/123] test dir cleanup: rename test/0001/dd79c8cfb8beeacd0460429944b4ecbe comment Renaming everything also means they have new creation-times, since we're now git logging a different file/dir. --- src/comment.rs | 9 +++++---- src/issues.rs | 11 ++++++++++- .../9055dac36045fe36545bed7ae7b49347/description | 3 +++ .../9055dac36045fe36545bed7ae7b49347/description | 3 --- 4 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description delete mode 100644 test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description diff --git a/src/comment.rs b/src/comment.rs index 9770d65..1fa2e36 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -212,16 +212,17 @@ mod tests { #[test] fn read_comment_0() { - let comment_dir = - std::path::Path::new("test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347"); + 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-07T15:26:26-06:00") + 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 dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), + 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/issues.rs b/src/issues.rs index e8b759f..8dca0cd 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -179,7 +179,16 @@ mod tests { let comment_uuid = String::from("9055dac36045fe36545bed7ae7b49347"); comment_dir.push("comments"); comment_dir.push(&comment_uuid); - let expected_comments = Vec::::new(); + 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, diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description new file mode 100644 index 0000000..daa3d62 --- /dev/null +++ b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description @@ -0,0 +1,3 @@ +This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe + +It has multiple lines diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description deleted file mode 100644 index f9de678..0000000 --- a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description +++ /dev/null @@ -1,3 +0,0 @@ -This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561 - -It has multiple lines From b3f5aaeb76652989c82cb9d2d655e8fec23cc81d Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Thu, 24 Jul 2025 08:38:40 -0600 Subject: [PATCH 121/123] test dir cleanup: rename test/0002/3fa5bfd93317ad25772680071d5ac325 Renaming everything also means they have new creation-times, since we're now git logging a different file/dir. --- src/issues.rs | 4 ++-- .../description | 0 .../state | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename test/0002/{3fa5bfd93317ad25772680071d5ac3259cd2384f => 3fa5bfd93317ad25772680071d5ac325}/description (100%) rename test/0002/{3fa5bfd93317ad25772680071d5ac3259cd2384f => 3fa5bfd93317ad25772680071d5ac325}/state (100%) diff --git a/src/issues.rs b/src/issues.rs index 8dca0cd..e991880 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -216,13 +216,13 @@ mod tests { let mut expected = Issues::new(); - let uuid = String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"); + 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-05T13:55:49-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:38:40-06:00") .unwrap() .with_timezone(&chrono::Local), done_time: None, diff --git a/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/description b/test/0002/3fa5bfd93317ad25772680071d5ac325/description similarity index 100% rename from test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/description rename to test/0002/3fa5bfd93317ad25772680071d5ac325/description diff --git a/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/state b/test/0002/3fa5bfd93317ad25772680071d5ac325/state similarity index 100% rename from test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/state rename to test/0002/3fa5bfd93317ad25772680071d5ac325/state From b3903a9ed2e25214239ae796878c2ba9adc559bd Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Thu, 24 Jul 2025 08:39:02 -0600 Subject: [PATCH 122/123] test dir cleanup: rename test/0002/a85f81fc5f14cb5d4851dd445dc9744c Renaming everything also means they have new creation-times, since we're now git logging a different file/dir. --- src/issues.rs | 8 ++++---- .../dependencies/3fa5bfd93317ad25772680071d5ac325} | 0 .../dependencies/dd79c8cfb8beeacd0460429944b4ecbe} | 0 .../description | 0 .../state | 0 5 files changed, 4 insertions(+), 4 deletions(-) rename test/0002/{a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies/3fa5bfd93317ad25772680071d5ac3259cd2384f => a85f81fc5f14cb5d4851dd445dc9744c/dependencies/3fa5bfd93317ad25772680071d5ac325} (100%) rename test/0002/{a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies/dd79c8cfb8beeacd0460429944b4ecbe95a31561 => a85f81fc5f14cb5d4851dd445dc9744c/dependencies/dd79c8cfb8beeacd0460429944b4ecbe} (100%) rename test/0002/{a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7 => a85f81fc5f14cb5d4851dd445dc9744c}/description (100%) rename test/0002/{a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7 => a85f81fc5f14cb5d4851dd445dc9744c}/state (100%) diff --git a/src/issues.rs b/src/issues.rs index e991880..f54fe87 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -256,22 +256,22 @@ mod tests { }, ); - let uuid = String::from("a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7"); + 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-05T13:55:49-06:00") + 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("3fa5bfd93317ad25772680071d5ac3259cd2384f"), - crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), + 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"), diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies/3fa5bfd93317ad25772680071d5ac3259cd2384f b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/dependencies/3fa5bfd93317ad25772680071d5ac325 similarity index 100% rename from test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies/3fa5bfd93317ad25772680071d5ac3259cd2384f rename to test/0002/a85f81fc5f14cb5d4851dd445dc9744c/dependencies/3fa5bfd93317ad25772680071d5ac325 diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies/dd79c8cfb8beeacd0460429944b4ecbe95a31561 b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/dependencies/dd79c8cfb8beeacd0460429944b4ecbe similarity index 100% rename from test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies/dd79c8cfb8beeacd0460429944b4ecbe95a31561 rename to test/0002/a85f81fc5f14cb5d4851dd445dc9744c/dependencies/dd79c8cfb8beeacd0460429944b4ecbe diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/description similarity index 100% rename from test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description rename to test/0002/a85f81fc5f14cb5d4851dd445dc9744c/description diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/state b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/state similarity index 100% rename from test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/state rename to test/0002/a85f81fc5f14cb5d4851dd445dc9744c/state From fad23ba233adc90a34175281552c5eadace9f661 Mon Sep 17 00:00:00 2001 From: sigil-03 Date: Thu, 24 Jul 2025 08:39:20 -0600 Subject: [PATCH 123/123] test dir cleanup: rename test/0002/dd79c8cfb8beeacd0460429944b4ecbe Renaming everything also means they have new creation-times, since we're now git logging a different file/dir. --- src/issues.rs | 4 ++-- .../description | 0 .../state | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename test/0002/{dd79c8cfb8beeacd0460429944b4ecbe95a31561 => dd79c8cfb8beeacd0460429944b4ecbe}/description (100%) rename test/0002/{dd79c8cfb8beeacd0460429944b4ecbe95a31561 => dd79c8cfb8beeacd0460429944b4ecbe}/state (100%) diff --git a/src/issues.rs b/src/issues.rs index f54fe87..d3c57c0 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -235,14 +235,14 @@ mod tests { dir, }); - let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"); + 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-05T13:55:49-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:39:20-06:00") .unwrap() .with_timezone(&chrono::Local), done_time: None, diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/description similarity index 100% rename from test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description rename to test/0002/dd79c8cfb8beeacd0460429944b4ecbe/description diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/state similarity index 100% rename from test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state rename to test/0002/dd79c8cfb8beeacd0460429944b4ecbe/state