From 35b68bbca15d2af022c77adfa4dbd447b39e4646 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 20 Jul 2025 12:45:30 -0600 Subject: [PATCH 01/11] tags is now a directory with a file per tag This is more conflict resistant than the old encoding where tags was a file with a line per tag. --- src/issue.rs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/issue.rs b/src/issue.rs index e3bf9d3..03cca1d 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -137,12 +137,7 @@ impl Issue { } else if file_name == "dependencies" && direntry.metadata()?.is_dir() { dependencies = Self::read_dependencies(&direntry.path())?; } else if file_name == "tags" { - let contents = std::fs::read_to_string(direntry.path())?; - tags = contents - .lines() - .filter(|s| s.len() > 0) - .map(|tag| String::from(tag.trim())) - .collect(); + tags = Self::read_tags(&direntry)?; } else if file_name == "comments" && direntry.metadata()?.is_dir() { Self::read_comments(&mut comments, &direntry.path())?; } else { @@ -217,6 +212,23 @@ impl Issue { Ok(dependencies) } + fn read_tags(tags_direntry: &std::fs::DirEntry) -> Result, IssueError> { + if !tags_direntry.metadata()?.is_dir() { + eprintln!("issue has old-style tags file"); + return Err(IssueError::StdIoError(std::io::Error::from( + std::io::ErrorKind::NotADirectory, + ))); + } + let mut tags = Vec::::new(); + for direntry in tags_direntry.path().read_dir()? { + if let Ok(direntry) = direntry { + tags.push(String::from(direntry.file_name().to_string_lossy())); + } + } + tags.sort(); + Ok(tags) + } + /// Add a new Comment to the Issue. Commits. pub fn add_comment( &mut self, From 6ddf787d9e52492069a5b4c3c2148c1e2ea80f18 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 20 Jul 2025 12:53:44 -0600 Subject: [PATCH 02/11] add a tool to migrate tags from files to dirs --- tools/update-tags-encoding | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100755 tools/update-tags-encoding diff --git a/tools/update-tags-encoding b/tools/update-tags-encoding new file mode 100755 index 0000000..87c5a5d --- /dev/null +++ b/tools/update-tags-encoding @@ -0,0 +1,35 @@ +#!/bin/bash +# +# Check out the `entomologist-data` branch in a temporary worktree. +# For each issue with a `tags` file: +# read + +set -e +#set -x + +WORKTREE_DIR=$(mktemp --directory) +git worktree add "${WORKTREE_DIR}" entomologist-data +pushd "${WORKTREE_DIR}" > /dev/null + +for ISSUE_ID in $(find . -maxdepth 1 -type d -regextype posix-extended -regex '\./[0-9a-f]{32}'); do + if ! [[ -f "${ISSUE_ID}/tags" ]]; then + continue + fi + + pushd "${ISSUE_ID}" > /dev/null + + echo "${ISSUE_ID} has tags:" + TAGS=$(cat tags) + git rm tags + mkdir tags + for TAG in ${TAGS}; do + touch "tags/${TAG}" + done + git add tags + #git commit -m "issue ${ISSUE_ID}: update tags to new format" + + popd > /dev/null +done + +popd > /dev/null +git worktree remove "${WORKTREE_DIR}" From e1287514f65fcc8052a4627170dc2c8018a8a353 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 23 Jul 2025 15:26:42 -0600 Subject: [PATCH 03/11] 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 04/11] 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 64b64efddc94c1e521bc1c2d49fd127dcc86f92e Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 23 Jul 2025 15:06:31 -0600 Subject: [PATCH 05/11] rename all test issues & comments to match our u128 standard --- .../description | 0 .../tags | 0 .../assignee | 0 .../description | 0 .../state | 0 .../description | 0 .../done_time | 0 .../state | 0 .../comments/9055dac36045fe36545bed7ae7b49347/description | 0 .../description | 0 .../state | 0 .../description | 0 .../state | 0 .../dependencies/3fa5bfd93317ad25772680071d5ac325} | 0 .../dependencies/dd79c8cfb8beeacd0460429944b4ecbe} | 0 .../description | 0 .../state | 0 .../description | 0 .../state | 0 19 files changed, 0 insertions(+), 0 deletions(-) rename test/0000/{3943fc5c173fdf41c0a22251593cd476d96e6c9f => 3943fc5c173fdf41c0a22251593cd476}/description (100%) rename test/0000/{3943fc5c173fdf41c0a22251593cd476d96e6c9f => 3943fc5c173fdf41c0a22251593cd476}/tags (100%) rename test/0000/{7792b063eef6d33e7da5dc1856750c149ba678c6 => 7792b063eef6d33e7da5dc1856750c14}/assignee (100%) rename test/0000/{7792b063eef6d33e7da5dc1856750c149ba678c6 => 7792b063eef6d33e7da5dc1856750c14}/description (100%) rename test/0000/{7792b063eef6d33e7da5dc1856750c149ba678c6 => 7792b063eef6d33e7da5dc1856750c14}/state (100%) rename test/0001/{3fa5bfd93317ad25772680071d5ac3259cd2384f => 3fa5bfd93317ad25772680071d5ac325}/description (100%) rename test/0001/{3fa5bfd93317ad25772680071d5ac3259cd2384f => 3fa5bfd93317ad25772680071d5ac325}/done_time (100%) rename test/0001/{3fa5bfd93317ad25772680071d5ac3259cd2384f => 3fa5bfd93317ad25772680071d5ac325}/state (100%) rename test/0001/{dd79c8cfb8beeacd0460429944b4ecbe95a31561 => dd79c8cfb8beeacd0460429944b4ecbe}/comments/9055dac36045fe36545bed7ae7b49347/description (100%) rename test/0001/{dd79c8cfb8beeacd0460429944b4ecbe95a31561 => dd79c8cfb8beeacd0460429944b4ecbe}/description (100%) rename test/0001/{dd79c8cfb8beeacd0460429944b4ecbe95a31561 => dd79c8cfb8beeacd0460429944b4ecbe}/state (100%) rename test/0002/{3fa5bfd93317ad25772680071d5ac3259cd2384f => 3fa5bfd93317ad25772680071d5ac325}/description (100%) rename test/0002/{3fa5bfd93317ad25772680071d5ac3259cd2384f => 3fa5bfd93317ad25772680071d5ac325}/state (100%) 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%) rename test/0002/{dd79c8cfb8beeacd0460429944b4ecbe95a31561 => dd79c8cfb8beeacd0460429944b4ecbe}/description (100%) rename test/0002/{dd79c8cfb8beeacd0460429944b4ecbe95a31561 => dd79c8cfb8beeacd0460429944b4ecbe}/state (100%) 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 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 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 diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description similarity index 100% rename from test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description rename to test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description 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 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 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 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 From 3b64acbf3f02708e7383f462ba55fb9ed4fe0230 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 23 Jul 2025 15:31:52 -0600 Subject: [PATCH 06/11] update all tests for renamed issue & comment ids Renaming everything also means they have new creation-times, since we're now git logging a different file/dir. --- src/comment.rs | 9 ++-- src/issue.rs | 12 +++--- src/issues.rs | 42 +++++++++---------- .../description | 2 +- 4 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/comment.rs b/src/comment.rs index 9770d65..6424e58 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-23T15:06:31-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/issue.rs b/src/issue.rs index 6f5889e..e50e79e 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-23T15:06:31-06:00") .unwrap() .with_timezone(&chrono::Local), done_time: None, @@ -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-23T15:06:31-06:00") .unwrap() .with_timezone(&chrono::Local), done_time: None, diff --git a/src/issues.rs b/src/issues.rs index fc182e7..c42d452 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-23T15:06:31-06:00") .unwrap() .with_timezone(&chrono::Local), done_time: None, @@ -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-23T15:06:31-06:00") .unwrap() .with_timezone(&chrono::Local), done_time: None, @@ -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-23T15:06:31-06:00") .unwrap() .with_timezone(&chrono::Local), done_time: Some( @@ -172,7 +172,7 @@ 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(); @@ -184,8 +184,8 @@ mod tests { 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"), + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-23T15:06:31-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), } ); @@ -193,7 +193,7 @@ mod tests { 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-23T15:06:31-06:00") .unwrap() .with_timezone(&chrono::Local), done_time: None, @@ -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") + author: String::from("Sebastian Kuzminsky "), + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-23T15:06:31-06:00") .unwrap() .with_timezone(&chrono::Local), done_time: None, @@ -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") + author: String::from("Sebastian Kuzminsky "), + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-23T15:06:31-06:00") .unwrap() .with_timezone(&chrono::Local), done_time: None, @@ -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") + author: String::from("Sebastian Kuzminsky "), + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-23T15:06:31-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/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description index f9de678..daa3d62 100644 --- a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description +++ b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description @@ -1,3 +1,3 @@ -This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561 +This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe It has multiple lines From a57482f6625b11c6403c395a837dd41f9b07d05c Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 20 Jul 2025 12:45:30 -0600 Subject: [PATCH 07/11] tags is now a directory with a file per tag This is more conflict resistant than the old encoding where tags was a file with a line per tag. --- src/issue.rs | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/issue.rs b/src/issue.rs index e50e79e..0118e10 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -137,13 +137,7 @@ impl Issue { } else if file_name == "dependencies" && direntry.metadata()?.is_dir() { dependencies = Self::read_dependencies(&direntry.path())?; } else if file_name == "tags" { - let contents = std::fs::read_to_string(direntry.path())?; - tags = contents - .lines() - .filter(|s| s.len() > 0) - .map(|tag| String::from(tag.trim())) - .collect(); - tags.sort(); + tags = Self::read_tags(&direntry)?; } else if file_name == "comments" && direntry.metadata()?.is_dir() { Self::read_comments(&mut comments, &direntry.path())?; } else { @@ -218,6 +212,23 @@ impl Issue { Ok(dependencies) } + fn read_tags(tags_direntry: &std::fs::DirEntry) -> Result, IssueError> { + if !tags_direntry.metadata()?.is_dir() { + eprintln!("issue has old-style tags file"); + return Err(IssueError::StdIoError(std::io::Error::from( + std::io::ErrorKind::NotADirectory, + ))); + } + let mut tags = Vec::::new(); + for direntry in tags_direntry.path().read_dir()? { + if let Ok(direntry) = direntry { + tags.push(String::from(direntry.file_name().to_string_lossy())); + } + } + tags.sort(); + Ok(tags) + } + /// Add a new Comment to the Issue. Commits. pub fn add_comment( &mut self, From 36ba5c3a12e2507778c6058cd44e028b7f930af6 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Sun, 20 Jul 2025 12:53:44 -0600 Subject: [PATCH 08/11] add a tool to migrate tags from files to dirs --- tools/update-tags-encoding | 71 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100755 tools/update-tags-encoding diff --git a/tools/update-tags-encoding b/tools/update-tags-encoding new file mode 100755 index 0000000..c06ba74 --- /dev/null +++ b/tools/update-tags-encoding @@ -0,0 +1,71 @@ +#!/bin/bash +# +# Check out the `entomologist-data` branch in a temporary worktree. +# For each issue with a `tags` file, replace the old-style tags file with a new-style tags dir. +# git commit + +set -e +#set -x + +BRANCH="" + +if [[ -n "$1" ]] && [[ -d "$1" ]]; then + echo "updating ent db in directory '$1'" + pushd "$1" +else + if [[ -n "$1" ]]; then + # better be a branch + BRANCH="$1" + else + BRANCH="entomologist-data" + fi + echo "updating ent db in branch '${BRANCH}'" + WORKTREE_DIR=$(mktemp --directory) + git worktree add "${WORKTREE_DIR}" "${BRANCH}" + pushd "${WORKTREE_DIR}" > /dev/null +fi + +# Now our current working directory is the ent db that we're supposed +# to update. +# +# If $BRANCH is empty, we're in a directory not tracked by git and we +# just change the files. +# +# If $BRANCH is not empty, we're in a git worktree of the branch we're +# supposed to change, so we commit as we go. + +for ISSUE_ID in $(find . -maxdepth 1 -type d -regextype posix-extended -regex '\./[0-9a-f]{32}'); do + ISSUE_ID=$(basename "${ISSUE_ID}") + if ! [[ -f "${ISSUE_ID}/tags" ]]; then + continue + fi + + pushd "${ISSUE_ID}" > /dev/null + + echo "${ISSUE_ID} has tags:" + TAGS=$(cat tags) + echo "${TAGS}" + rm tags + + if [[ -n "${BRANCH}" ]]; then + git rm -f tags + fi + + mkdir tags + for TAG in ${TAGS}; do + touch "tags/${TAG}" + done + + if [[ -n "${BRANCH}" ]]; then + git add tags + git commit -m "issue ${ISSUE_ID}: update tags to new format" + fi + + popd > /dev/null +done + +popd > /dev/null + +if [[ -n "${BRANCH}" ]]; then + git worktree remove "${WORKTREE_DIR}" +fi From 64979ad603dc0a8cfc9f0ddc93a1902dd1803dc8 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 23 Jul 2025 15:14:13 -0600 Subject: [PATCH 09/11] update test/0000 tags --- test/0000/3943fc5c173fdf41c0a22251593cd476/tags | 3 --- test/0000/3943fc5c173fdf41c0a22251593cd476/tags/TAG2 | 0 .../0000/3943fc5c173fdf41c0a22251593cd476/tags/i-am-also-a-tag | 0 test/0000/3943fc5c173fdf41c0a22251593cd476/tags/tag1 | 0 4 files changed, 3 deletions(-) delete mode 100644 test/0000/3943fc5c173fdf41c0a22251593cd476/tags create mode 100644 test/0000/3943fc5c173fdf41c0a22251593cd476/tags/TAG2 create mode 100644 test/0000/3943fc5c173fdf41c0a22251593cd476/tags/i-am-also-a-tag create mode 100644 test/0000/3943fc5c173fdf41c0a22251593cd476/tags/tag1 diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476/tags b/test/0000/3943fc5c173fdf41c0a22251593cd476/tags deleted file mode 100644 index 04e82a6..0000000 --- a/test/0000/3943fc5c173fdf41c0a22251593cd476/tags +++ /dev/null @@ -1,3 +0,0 @@ -tag1 -TAG2 -i-am-also-a-tag diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476/tags/TAG2 b/test/0000/3943fc5c173fdf41c0a22251593cd476/tags/TAG2 new file mode 100644 index 0000000..e69de29 diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476/tags/i-am-also-a-tag b/test/0000/3943fc5c173fdf41c0a22251593cd476/tags/i-am-also-a-tag new file mode 100644 index 0000000..e69de29 diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476/tags/tag1 b/test/0000/3943fc5c173fdf41c0a22251593cd476/tags/tag1 new file mode 100644 index 0000000..e69de29 From eace6ca35d50f2f5cabd056797cc2808c8597b0e Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 23 Jul 2025 20:23:03 -0600 Subject: [PATCH 10/11] refactor Issue::read_tags() to handle escaping --- src/issue.rs | 133 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 116 insertions(+), 17 deletions(-) diff --git a/src/issue.rs b/src/issue.rs index 0118e10..c206674 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -48,6 +48,10 @@ pub enum IssueError { ChronoParseError(#[from] chrono::format::ParseError), #[error("Failed to parse issue")] IssueParseError, + #[error("invalid escape character {escape:?} in tag file {filename:?}")] + TagInvalidEscape { escape: String, filename: String }, + #[error("invalid trailing escape character ',' in tag file {filename:?}")] + TagTrailingEscape { filename: String }, #[error("Failed to parse state")] StateParseError, #[error("Failed to run git")] @@ -212,23 +216,6 @@ impl Issue { Ok(dependencies) } - fn read_tags(tags_direntry: &std::fs::DirEntry) -> Result, IssueError> { - if !tags_direntry.metadata()?.is_dir() { - eprintln!("issue has old-style tags file"); - return Err(IssueError::StdIoError(std::io::Error::from( - std::io::ErrorKind::NotADirectory, - ))); - } - let mut tags = Vec::::new(); - for direntry in tags_direntry.path().read_dir()? { - if let Ok(direntry) = direntry { - tags.push(String::from(direntry.file_name().to_string_lossy())); - } - } - tags.sort(); - Ok(tags) - } - /// Add a new Comment to the Issue. Commits. pub fn add_comment( &mut self, @@ -536,6 +523,60 @@ impl Issue { Ok(()) } + fn read_tags(tags_direntry: &std::fs::DirEntry) -> Result, IssueError> { + if !tags_direntry.metadata()?.is_dir() { + eprintln!("issue has old-style tags file"); + return Err(IssueError::IssueParseError); + } + let mut tags = Vec::::new(); + for direntry in tags_direntry.path().read_dir()? { + if let Ok(direntry) = direntry { + let tag = Issue::tag_from_filename(&direntry.file_name().to_string_lossy())?; + tags.push(tag); + } + } + tags.sort(); + Ok(tags) + } + + /// Perform un-escape on a filename to make it into a tag: + /// ",0" => "," + /// ",1" => "/" + fn tag_from_filename(filename: &str) -> Result { + let mut tag = String::new(); + let mut token_iter = filename.split(','); + let Some(start) = token_iter.next() else { + return Err(IssueError::StdIoError(std::io::Error::from( + std::io::ErrorKind::NotFound, + ))); + }; + tag.push_str(start); + for token in token_iter { + match token.chars().nth(0) { + Some('0') => { + tag.push(','); + tag.push_str(&token[1..]); + } + Some('1') => { + tag.push('/'); + tag.push_str(&token[1..]); + } + Some(bogus) => { + return Err(IssueError::TagInvalidEscape { + escape: String::from(bogus), + filename: String::from(filename), + }); + } + None => { + return Err(IssueError::TagTrailingEscape { + filename: String::from(filename), + }); + } + } + } + Ok(tag) + } + fn commit_tags(&self, commit_message: &str) -> Result<(), IssueError> { let mut tags_filename = self.dir.clone(); tags_filename.push("tags"); @@ -562,6 +603,64 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + fn parse_tag_0() { + assert_eq!( + Issue::tag_from_filename("hello").unwrap(), + String::from("hello") + ); + } + + #[test] + fn parse_tag_1() { + assert_eq!( + Issue::tag_from_filename("hello,0world").unwrap(), + String::from("hello,world") + ); + } + + #[test] + fn parse_tag_2() { + assert_eq!( + Issue::tag_from_filename("hello,1world").unwrap(), + String::from("hello/world") + ); + } + + #[test] + fn parse_tag_3() { + assert_eq!( + Issue::tag_from_filename(",0hello,1world,0").unwrap(), + String::from(",hello/world,") + ); + } + + #[test] + fn parse_tag_4() { + // std::io::Error does not impl PartialEq :-( + let filename = "hello,"; + match Issue::tag_from_filename(filename) { + Ok(tag) => panic!( + "tag_from_filename() accepted invalid input {:?} and returned {:?}", + filename, tag + ), + Err(_e) => (), + } + } + + #[test] + fn parse_tag_5() { + // std::io::Error does not impl PartialEq :-( + let filename = "hello,world"; + match Issue::tag_from_filename(filename) { + Ok(tag) => panic!( + "tag_from_filename() accepted invalid input {:?} and returned {:?}", + filename, tag + ), + Err(_e) => (), + } + } + #[test] fn read_issue_0() { let issue_dir = std::path::Path::new("test/0000/3943fc5c173fdf41c0a22251593cd476/"); From b1c32fbf63a4fe064690975d31c49648c6de6cb0 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Wed, 23 Jul 2025 20:24:42 -0600 Subject: [PATCH 11/11] add some tags with escapes to the tests --- src/issue.rs | 5 +++++ src/issues.rs | 5 +++++ test/0000/3943fc5c173fdf41c0a22251593cd476/tags/bird,1wing | 0 .../tags/bird,1wing,1feather | 0 test/0000/3943fc5c173fdf41c0a22251593cd476/tags/deer,0antler | 0 .../tags/deer,0antler,0tassle | 0 .../3943fc5c173fdf41c0a22251593cd476/tags/hop,0scotch,1shoe | 0 7 files changed, 10 insertions(+) create mode 100644 test/0000/3943fc5c173fdf41c0a22251593cd476/tags/bird,1wing create mode 100644 test/0000/3943fc5c173fdf41c0a22251593cd476/tags/bird,1wing,1feather create mode 100644 test/0000/3943fc5c173fdf41c0a22251593cd476/tags/deer,0antler create mode 100644 test/0000/3943fc5c173fdf41c0a22251593cd476/tags/deer,0antler,0tassle create mode 100644 test/0000/3943fc5c173fdf41c0a22251593cd476/tags/hop,0scotch,1shoe diff --git a/src/issue.rs b/src/issue.rs index c206674..1db0792 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -674,6 +674,11 @@ mod tests { done_time: None, tags: Vec::::from([ String::from("TAG2"), + String::from("bird/wing"), + String::from("bird/wing/feather"), + String::from("deer,antler"), + String::from("deer,antler,tassle"), + String::from("hop,scotch/shoe"), String::from("i-am-also-a-tag"), String::from("tag1"), ]), diff --git a/src/issues.rs b/src/issues.rs index c42d452..8db4c26 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -128,6 +128,11 @@ mod tests { done_time: None, tags: Vec::::from([ String::from("TAG2"), + String::from("bird/wing"), + String::from("bird/wing/feather"), + String::from("deer,antler"), + String::from("deer,antler,tassle"), + String::from("hop,scotch/shoe"), String::from("i-am-also-a-tag"), String::from("tag1"), ]), diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476/tags/bird,1wing b/test/0000/3943fc5c173fdf41c0a22251593cd476/tags/bird,1wing new file mode 100644 index 0000000..e69de29 diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476/tags/bird,1wing,1feather b/test/0000/3943fc5c173fdf41c0a22251593cd476/tags/bird,1wing,1feather new file mode 100644 index 0000000..e69de29 diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476/tags/deer,0antler b/test/0000/3943fc5c173fdf41c0a22251593cd476/tags/deer,0antler new file mode 100644 index 0000000..e69de29 diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476/tags/deer,0antler,0tassle b/test/0000/3943fc5c173fdf41c0a22251593cd476/tags/deer,0antler,0tassle new file mode 100644 index 0000000..e69de29 diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476/tags/hop,0scotch,1shoe b/test/0000/3943fc5c173fdf41c0a22251593cd476/tags/hop,0scotch,1shoe new file mode 100644 index 0000000..e69de29