task

creates a plain-text task file in the current directory
git clone git://brookjeynes.dev/bjeynes/task.git
Log | Files | Refs | README | LICENSE

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:
Mmain.c | 191++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mmanifesto.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