commit 7a877d52980a67110336240fed83a13fc9a3cc8f
parent ae9352218f2d7d9ed2b9dc59be4096957c087577
Author: brookjeynes <me@brookjeynes.dev>
Date: Tue, 16 Jun 2026 10:15:03 +1000
feat: add id
Signed-off-by: brookjeynes <me@brookjeynes.dev>
Diffstat:
| M | main.c | | | 191 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| M | manifesto.txt | | | 50 | +++++++++++++++++++++++++++++++++++++++++++++----- |
2 files changed, 234 insertions(+), 7 deletions(-)
diff --git a/main.c b/main.c
@@ -1,12 +1,20 @@
#define _DEFAULT_SOURCE
-
#include <dirent.h>
#include <errno.h>
+#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
+#include <sys/random.h>
#include <unistd.h>
+#define TASK_ID_BYTES 8
+#define TASK_ID_CHARS (TASK_ID_BYTES * 2)
+#define TASKS_DIR "."
+#define DONE_TASKS_DIR "done"
+
+static const char reverse_hex[] = "klmnopqrstuvwxyz";
+
struct options {
char *title;
char priority;
@@ -29,6 +37,172 @@ int has_txt_extension(const char *name) {
return len >= 4 && strcmp(name + len - 4, ".txt") == 0;
}
+int generate_task_id(char out[TASK_ID_CHARS + 1]) {
+ uint8_t bytes[TASK_ID_BYTES];
+
+ size_t got = 0;
+ while (got < sizeof(bytes)) {
+ ssize_t n = getrandom(bytes + got, sizeof(bytes) - got, 0);
+ if (n == -1) {
+ return -1;
+ }
+ got += n;
+ }
+
+ for (size_t i = 0; i < TASK_ID_BYTES; i++) {
+ out[i * 2] = reverse_hex[bytes[i] >> 4];
+ out[i * 2 + 1] = reverse_hex[bytes[i] & 0x0f];
+ }
+
+ out[TASK_ID_CHARS] = '\0';
+ return 0;
+}
+
+int task_id_exists_in_dir(const char *dir, const char *id, int allow_missing) {
+ struct dirent *dp;
+ FILE *fd = NULL;
+ int rc = -1;
+
+ DIR *dirp = opendir(dir);
+ if (dirp == NULL) {
+ if (allow_missing && errno == ENOENT) {
+ return 0;
+ }
+
+ fprintf(stderr, "err: failed to open directory: %s\n", strerror(errno));
+ goto cleanup;
+ }
+
+ for (;;) {
+ errno = 0;
+ dp = readdir(dirp);
+ if (dp == NULL) {
+ if (errno == 0) {
+ break;
+ }
+ fprintf(stderr, "err: failed to iterate directory: %s\n",
+ strerror(errno));
+ goto cleanup;
+ }
+
+ if (dp->d_type != DT_REG) {
+ continue;
+ }
+
+ if (has_txt_extension(dp->d_name) == 0) {
+ continue;
+ }
+
+ char path[1024];
+ int path_len;
+ if (strcmp(dir, ".") == 0) {
+ path_len = snprintf(path, sizeof path, "%s", dp->d_name);
+ } else {
+ path_len = snprintf(path, sizeof path, "%s/%s", dir, dp->d_name);
+ }
+
+ if (path_len < 0 || (size_t)path_len >= sizeof path) {
+ fprintf(stderr, "err: task file path too long\n");
+ goto cleanup;
+ }
+
+ fd = fopen(path, "r");
+ if (fd == NULL) {
+ fprintf(stderr, "err: failed to open file: %s\n", strerror(errno));
+ goto cleanup;
+ }
+
+ char line[1024];
+ if (fgets(line, sizeof line, fd) == NULL) {
+ if (ferror(fd)) {
+ fprintf(stderr, "err: failed to read file: %s\n", strerror(errno));
+ goto cleanup;
+ }
+
+ // empty file
+ if (fclose(fd) != 0) {
+ fprintf(stderr, "err: failed to close file: %s\n", strerror(errno));
+ goto cleanup;
+ }
+
+ fd = NULL;
+ continue;
+ }
+
+ if (fgets(line, sizeof line, fd) == NULL) {
+ if (ferror(fd)) {
+ fprintf(stderr, "err: failed to read file: %s\n", strerror(errno));
+ goto cleanup;
+ }
+
+ if (fclose(fd) != 0) {
+ fprintf(stderr, "err: failed to close file: %s\n", strerror(errno));
+ goto cleanup;
+ }
+
+ fd = NULL;
+ continue;
+ }
+
+ char expected[TASK_ID_CHARS + 6]; // "id: " + 16 + "\n" + "\0"
+ snprintf(expected, sizeof expected, "id: %s\n", id);
+ if (strcmp(line, expected) == 0) {
+ rc = 1;
+ goto cleanup;
+ }
+
+ if (fclose(fd) != 0) {
+ fd = NULL;
+ fprintf(stderr, "err: failed to close file: %s\n", strerror(errno));
+ goto cleanup;
+ }
+ fd = NULL;
+ }
+
+ rc = 0;
+
+cleanup:
+ if (fd != NULL) {
+ if (fclose(fd) != 0) {
+ fprintf(stderr, "err: failed to close file: %s\n", strerror(errno));
+ rc = -1;
+ }
+ }
+
+ if (dirp != NULL && closedir(dirp) != 0) {
+ fprintf(stderr, "err: failed to close directory: %s\n", strerror(errno));
+ rc = -1;
+ }
+
+ return rc;
+}
+
+int task_id_exists(const char *id) {
+ int exists = task_id_exists_in_dir(TASKS_DIR, id, 0);
+ if (exists != 0) {
+ return exists;
+ }
+
+ return task_id_exists_in_dir(DONE_TASKS_DIR, id, 1);
+}
+
+int generate_unique_task_id(char id[TASK_ID_CHARS + 1]) {
+ for (;;) {
+ if (generate_task_id(id) != 0) {
+ return -1;
+ }
+
+ int exists = task_id_exists(id);
+ if (exists == -1) {
+ return -1;
+ }
+
+ if (!exists) {
+ return 0;
+ }
+ }
+}
+
static void print_properties(char identifier) {
struct dirent *dp;
FILE *fd = NULL;
@@ -37,7 +211,7 @@ static void print_properties(char identifier) {
char *properties[1024];
size_t properties_len = 0;
- DIR *dirp = opendir(".");
+ DIR *dirp = opendir(TASKS_DIR);
if (dirp == NULL) {
fprintf(stderr, "err: failed to open directory: %s\n", strerror(errno));
goto cleanup;
@@ -270,6 +444,12 @@ int main(int argc, char *argv[]) {
}
strcpy(title_to_file + title_len, ".txt");
+ char id[TASK_ID_CHARS + 1];
+ if (generate_unique_task_id(id) != 0) {
+ perror("generate_unique_task_id");
+ return 1;
+ }
+
FILE *f = fopen(title_to_file, "wx");
if (f == NULL) {
fprintf(stderr, "err: failed to open file: %s\n", strerror(errno));
@@ -319,6 +499,13 @@ int main(int argc, char *argv[]) {
}
}
+ if (fprintf(f, "id: %s\n", id) < 0) {
+ if (!write_failed) {
+ write_failed = 1;
+ write_errno = errno;
+ }
+ }
+
int close_failed = 0;
int close_errno = 0;
if (fclose(f) != 0) {
diff --git a/manifesto.txt b/manifesto.txt
@@ -52,7 +52,7 @@ Philosophically, this format has two goals:
should be able to sort your task list in a meaningful way.
-Incomplete Tasks: 3 Format Rules
+Incomplete Tasks: 4 Format Rules
--------------------------------
The beauty of this format is that it's completely unstructured; the fields you
@@ -86,8 +86,17 @@ $ grep -r '@ui\|@bug' tasks
tasks/checkout-race-condition.txt:(A) Checkout race condition @bug +checkout
tasks/done/checkout-ui-design.txt:update ui to match new styleguide @ui +checkout
+# Query for a task with the id "mxkpzqvnwolsrktu"
+$ grep -r '^id: mxkpzqvnwolsrktu' tasks
+tasks/add_release_types.txt:id: mxkpzqvnwolsrktu
-There are three formatting rules for current todo's.
+# Usually the first 2-4 characters are sufficient.
+# Query for a task with an id starting with "mx"
+$ grep -r '^id: mx' tasks
+tasks/add_release_types.txt:id: mxkpzqvnwolsrktu
+
+
+There are four formatting rules for current todo's.
Rule 1: If priority exists, it ALWAYS appears first.
The priority is an uppercase character from A-Z enclosed in parentheses and
@@ -112,17 +121,48 @@ Rule 2: Project and context may appear AFTER the title.
+checkout UI rework
-Rule 3: Additional context may appear on the 3rd line of the file onwards.
+Rule 3: Additional context may appear after the metadata block.
A task does not have to have additional context. If it does, it should be
- on the 3rd line of the task file.
+ separated form the task title and metadata by a blank line.
This task has additional context
(A) checkout race condition @bug +checkout
Add a reservation hold on the orders table
+Rule 4: If an ID exists, it appears on the 2nd line of the file.
+ The ID is written as 'id:' followed by a space and a 16-character lowercase
+ identifier made up of the letters k-z. This represents 8 bytes encoded in
+ reverse-hex notation.
+
+ The ID is intended to be stable for the life of the task. Editing the task
+ title, priority, project, context, or filename should not change the ID.
+ When a task is completed and moved into the `tasks/done` folder, its ID
+ should be preserved.
+
+ Tools that create new tasks should generate a random 8-byte ID, encode it
+ using the letters k-z, and check existing task files for a collision before
+ writing the new task.
+
+ This task has an ID:
+ (A) checkout race condition @bug +checkout
+ id: mxkpzqvnwolsrktu
+
+ This task does not have an ID:
+ (A) checkout race condition @bug +checkout
+
+ These tasks do not have valid IDs:
+ (A) checkout race condition @bug +checkout
+ ID: mxkpzqvnwolsrktu
+
+ (A) checkout race condition @bug +checkout
+ id: mxkpzqvnwol
+
+ (A) checkout race condition @bug +checkout
+ id: abcdef1234567890
+
-Completed Tasks
+Complete Tasks
---------------
Once a task has been completed, it is moved into the tasks/done folder. Tasks