task

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

task.c (15803B)


      1 #define _DEFAULT_SOURCE
      2 #include <dirent.h>
      3 #include <errno.h>
      4 #include <git2.h>
      5 #include <git2/commit.h>
      6 #include <stdint.h>
      7 #include <stdio.h>
      8 #include <stdlib.h>
      9 #include <string.h>
     10 #include <sys/random.h>
     11 #include <sys/stat.h>
     12 #include <unistd.h>
     13 
     14 #define TASK_ID_BYTES 8
     15 #define TASK_ID_CHARS (TASK_ID_BYTES * 2)
     16 #define TASKS_DIR "tasks"
     17 #define TASKS_PATH                                                             \
     18   "."                                                                          \
     19   "/" TASKS_DIR
     20 #define DONE_TASKS_DIR "done"
     21 #define DONE_TASKS_PATH TASKS_PATH "/" DONE_TASKS_DIR
     22 
     23 static const char reverse_hex[] = "klmnopqrstuvwxyz";
     24 
     25 struct options {
     26   char *title;
     27   char priority;
     28 };
     29 
     30 static int filter_entries(const struct dirent *dp) {
     31   if (strcmp(dp->d_name, ".") == 0 || strcmp(dp->d_name, "..") == 0) {
     32     return 0;
     33   }
     34   if (strcmp(dp->d_name, DONE_TASKS_DIR) == 0) {
     35     return 0;
     36   }
     37   return 1;
     38 }
     39 
     40 static int count_files_scandir(const char *dir_path) {
     41   struct dirent **namelist;
     42   int count;
     43 
     44   count = scandir(dir_path, &namelist, filter_entries, alphasort);
     45   if (count == -1) {
     46     return -1;
     47   }
     48 
     49   for (int i = 0; i < count; i++) {
     50     free(namelist[i]);
     51   }
     52   free(namelist);
     53 
     54   return count;
     55 }
     56 
     57 static void print_help(const char *program) {
     58   printf("usage: %s <command> [options]\n\n"
     59          "commands:\n"
     60          "  commits <task-id>             list all commits tied <task-id>.\n"
     61          "  new <title> [properties...]   create a new task.\n"
     62          "  summary                       list contexts, projects, etc.\n"
     63          "\noptions:\n"
     64          "  -h, --help                    show this help\n"
     65          "  -p, --priority A              set priority A-Z\n",
     66          program);
     67 }
     68 
     69 static int has_txt_extension(const char *name) {
     70   size_t len = strlen(name);
     71   return len >= 4 && strcmp(name + len - 4, ".txt") == 0;
     72 }
     73 
     74 static int generate_task_id(char out[TASK_ID_CHARS + 1]) {
     75   uint8_t bytes[TASK_ID_BYTES];
     76 
     77   size_t got = 0;
     78   while (got < sizeof(bytes)) {
     79     ssize_t n = getrandom(bytes + got, sizeof(bytes) - got, 0);
     80     if (n == -1) {
     81       return -1;
     82     }
     83     got += n;
     84   }
     85 
     86   for (size_t i = 0; i < TASK_ID_BYTES; i++) {
     87     out[i * 2] = reverse_hex[bytes[i] >> 4];
     88     out[i * 2 + 1] = reverse_hex[bytes[i] & 0x0f];
     89   }
     90 
     91   out[TASK_ID_CHARS] = '\0';
     92   return 0;
     93 }
     94 
     95 static int task_id_exists_in_dir(const char *dir, const char *id,
     96                                  int allow_missing) {
     97   struct dirent *dp;
     98   FILE *fd = NULL;
     99   int rc = -1;
    100 
    101   DIR *dirp = opendir(dir);
    102   if (dirp == NULL) {
    103     if (allow_missing && errno == ENOENT) {
    104       return 0;
    105     }
    106 
    107     fprintf(stderr, "err: failed to open %s: %s\n", dir, strerror(errno));
    108     goto cleanup;
    109   }
    110 
    111   for (;;) {
    112     errno = 0;
    113     dp = readdir(dirp);
    114     if (dp == NULL) {
    115       if (errno == 0) {
    116         break;
    117       }
    118       perror("err: failed to iterate directory");
    119       goto cleanup;
    120     }
    121 
    122     if (dp->d_type != DT_REG) {
    123       continue;
    124     }
    125 
    126     if (has_txt_extension(dp->d_name) == 0) {
    127       continue;
    128     }
    129 
    130     char path[4096];
    131     int path_len;
    132     if (strcmp(dir, ".") == 0) {
    133       path_len = snprintf(path, sizeof path, "%s", dp->d_name);
    134     } else {
    135       path_len = snprintf(path, sizeof path, "%s/%s", dir, dp->d_name);
    136     }
    137 
    138     if (path_len < 0 || (size_t)path_len >= sizeof path) {
    139       fprintf(stderr, "err: task file path too long\n");
    140       goto cleanup;
    141     }
    142 
    143     fd = fopen(path, "r");
    144     if (fd == NULL) {
    145       perror("err: failed to open file");
    146       goto cleanup;
    147     }
    148 
    149     char line[1024];
    150     if (fgets(line, sizeof line, fd) == NULL) {
    151       if (ferror(fd)) {
    152         perror("err: failed to read file");
    153         goto cleanup;
    154       }
    155 
    156       // empty file
    157       if (fclose(fd) != 0) {
    158         perror("err: failed to close file");
    159         goto cleanup;
    160       }
    161 
    162       fd = NULL;
    163       continue;
    164     }
    165 
    166     if (fgets(line, sizeof line, fd) == NULL) {
    167       if (ferror(fd)) {
    168         perror("err: failed to read file");
    169         goto cleanup;
    170       }
    171 
    172       if (fclose(fd) != 0) {
    173         perror("err: failed to close file");
    174         goto cleanup;
    175       }
    176 
    177       fd = NULL;
    178       continue;
    179     }
    180 
    181     char expected[TASK_ID_CHARS + 6]; // "id: " + 16 + "\n" + "\0"
    182     snprintf(expected, sizeof expected, "id: %s\n", id);
    183     if (strcmp(line, expected) == 0) {
    184       rc = 1;
    185       goto cleanup;
    186     }
    187 
    188     if (fclose(fd) != 0) {
    189       fd = NULL;
    190       perror("err: failed to close file");
    191       goto cleanup;
    192     }
    193     fd = NULL;
    194   }
    195 
    196   rc = 0;
    197 
    198 cleanup:
    199   if (fd != NULL) {
    200     if (fclose(fd) != 0) {
    201       perror("err: failed to close file");
    202       rc = -1;
    203     }
    204   }
    205 
    206   if (dirp != NULL && closedir(dirp) != 0) {
    207     perror("err: failed to close directory");
    208     rc = -1;
    209   }
    210 
    211   return rc;
    212 }
    213 
    214 static int task_id_exists(const char *id) {
    215   int exists = task_id_exists_in_dir(TASKS_PATH, id, 0);
    216   if (exists != 0) {
    217     return exists;
    218   }
    219 
    220   return task_id_exists_in_dir(DONE_TASKS_PATH, id, 1);
    221 }
    222 
    223 static int generate_unique_task_id(char id[TASK_ID_CHARS + 1]) {
    224   for (;;) {
    225     if (generate_task_id(id) != 0) {
    226       return -1;
    227     }
    228 
    229     int exists = task_id_exists(id);
    230     if (exists == -1) {
    231       return -1;
    232     }
    233 
    234     if (!exists) {
    235       return 0;
    236     }
    237   }
    238 }
    239 
    240 static void print_properties(char identifier) {
    241   struct dirent *dp;
    242   FILE *fd = NULL;
    243   int rc = 1;
    244 
    245   char *properties[1024];
    246   size_t properties_len = 0;
    247 
    248   DIR *dirp = opendir(TASKS_PATH);
    249   if (dirp == NULL) {
    250     perror("err: failed to open tasks directory");
    251     goto cleanup;
    252   }
    253 
    254   for (;;) {
    255     errno = 0;
    256     dp = readdir(dirp);
    257     if (dp == NULL) {
    258       if (errno == 0) {
    259         break;
    260       }
    261       perror("err: failed to iterate directory");
    262       goto cleanup;
    263     }
    264 
    265     if (dp->d_type != DT_REG) {
    266       continue;
    267     }
    268 
    269     if (has_txt_extension(dp->d_name) == 0) {
    270       continue;
    271     }
    272 
    273     char path[4096];
    274     snprintf(path, sizeof(path), "%s/%s", TASKS_PATH, dp->d_name);
    275 
    276     fd = fopen(path, "r");
    277     if (fd == NULL) {
    278       perror("err: failed to open file");
    279       goto cleanup;
    280     }
    281 
    282     char line[1024];
    283     if (fgets(line, sizeof line, fd) == NULL) {
    284       if (ferror(fd)) {
    285         perror("err: failed to read file");
    286         goto cleanup;
    287       }
    288 
    289       // empty file
    290       if (fclose(fd) != 0) {
    291         perror("err: failed to close file");
    292         goto cleanup;
    293       }
    294 
    295       fd = NULL;
    296       continue;
    297     }
    298 
    299     size_t byte = 0;
    300     while (line[byte] != '\0') {
    301       char c = line[byte];
    302 
    303       if (c == identifier) {
    304         char property[1024];
    305         size_t property_len = 0;
    306 
    307         while (line[byte] != ' ' && line[byte] != '\n' && line[byte] != '\0' &&
    308                property_len < sizeof(property) - 1) {
    309           property[property_len] = line[byte];
    310           property_len++;
    311           byte++;
    312         }
    313         property[property_len] = '\0';
    314         property_len++;
    315 
    316         char *property_ptr = malloc(sizeof(char) * property_len);
    317         if (property_ptr == NULL) {
    318           perror("err: failed to alloc space for read property");
    319           goto cleanup;
    320         }
    321 
    322         if (property_len < sizeof(property)) {
    323           memcpy(property_ptr, property, property_len);
    324         }
    325 
    326         if (properties_len == sizeof(properties) / sizeof(properties[0])) {
    327           fprintf(stderr, "err: too many properties\n");
    328           free(property_ptr);
    329           goto cleanup;
    330         }
    331 
    332         properties[properties_len] = property_ptr;
    333         properties_len++;
    334       } else {
    335         byte++;
    336       }
    337     }
    338 
    339     if (fclose(fd) != 0) {
    340       fd = NULL;
    341       perror("err: failed to close file");
    342       goto cleanup;
    343     }
    344     fd = NULL;
    345   }
    346 
    347   rc = 0;
    348 
    349   for (size_t i = 0; i < properties_len; i++) {
    350     int is_duplicate = 0;
    351     for (size_t j = i; j > 0; j--) {
    352       if (strcmp(properties[i], properties[j - 1]) == 0) {
    353         is_duplicate = 1;
    354         break;
    355       }
    356     }
    357 
    358     if (is_duplicate == 1) {
    359       continue;
    360     }
    361 
    362     printf("%s\n", properties[i]);
    363   }
    364 
    365 cleanup:
    366   if (fd != NULL) {
    367     fclose(fd);
    368   }
    369 
    370   for (size_t i = 0; i < properties_len; i++) {
    371     free(properties[i]);
    372   }
    373 
    374   if (dirp != NULL && closedir(dirp) != 0 && rc == 0) {
    375     perror("err: failed to close directory");
    376     rc = 1;
    377   }
    378 
    379   if (rc != 0) {
    380     exit(1);
    381   }
    382 }
    383 
    384 static int new_command(int argc, char *argv[], struct options *cli_args) {
    385   if (argc == 1) {
    386     fprintf(stderr, "err: title required\n");
    387     return 1;
    388   }
    389 
    390   int positionals = 0;
    391   for (int idx = 1; idx < argc; idx++) {
    392     if (strcmp(argv[idx], "--priority") == 0 || strcmp(argv[idx], "-p") == 0) {
    393       if (cli_args->priority != '\0') {
    394         fprintf(stderr, "err: priority specified multiple times\n");
    395         return 1;
    396       }
    397 
    398       if (idx + 1 == argc) {
    399         fprintf(stderr, "err: priority required\n");
    400         return 1;
    401       }
    402 
    403       if (strcmp(argv[idx + 1], "") == 0) {
    404         fprintf(stderr, "err: priority can not be empty\n");
    405         return 1;
    406       }
    407 
    408       if (strlen(argv[idx + 1]) != 1) {
    409         fprintf(stderr,
    410                 "err: priority must be a single letter between [A-Z]\n");
    411         return 1;
    412       }
    413 
    414       cli_args->priority = argv[idx + 1][0];
    415       idx++;
    416       continue;
    417     }
    418 
    419     if (strcmp(argv[idx], "") == 0) {
    420       fprintf(stderr, "err: argument can not be empty\n");
    421       return 1;
    422     }
    423 
    424     if (positionals == 0) {
    425       cli_args->title = argv[idx];
    426     }
    427 
    428     positionals++;
    429   }
    430 
    431   if (cli_args->title == NULL) {
    432     fprintf(stderr, "err: title required\n");
    433     return 1;
    434   }
    435 
    436   if (cli_args->priority != '\0') {
    437     if (cli_args->priority < 'A' || cli_args->priority > 'Z') {
    438       fprintf(stderr, "err: priority must be between [A-Z]\n");
    439       return 1;
    440     }
    441   }
    442 
    443   size_t title_len = strlen(cli_args->title);
    444   if (title_len > 251) {
    445     fprintf(stderr, "err: title too long for file name\n");
    446     return 1;
    447   }
    448 
    449   char title_to_file[256];
    450   int in_leading_dots = 1;
    451   for (size_t idx = 0; idx < title_len; idx++) {
    452     if ((in_leading_dots && cli_args->title[idx] == '.') ||
    453         cli_args->title[idx] == ' ' || cli_args->title[idx] == '/' ||
    454         cli_args->title[idx] == '\\') {
    455       title_to_file[idx] = '_';
    456     } else {
    457       in_leading_dots = 0;
    458       title_to_file[idx] = cli_args->title[idx];
    459     }
    460   }
    461   strcpy(title_to_file + title_len, ".txt");
    462 
    463   char path[4096];
    464   snprintf(path, sizeof(path), "%s/%s", TASKS_PATH, title_to_file);
    465 
    466   struct stat st = {0};
    467   if (stat(TASKS_PATH, &st) == -1) {
    468     mkdir(TASKS_PATH, 0700);
    469   }
    470 
    471   DIR *tasks_dir = opendir(TASKS_PATH);
    472   if (tasks_dir == NULL) {
    473     if (errno == ENOENT) {
    474     }
    475 
    476     fprintf(stderr, "err: failed to check for %s: %s\n", TASKS_PATH,
    477             strerror(errno));
    478     return 1;
    479   }
    480   closedir(tasks_dir);
    481 
    482   FILE *f = fopen(path, "wx");
    483   if (f == NULL) {
    484     perror("err: failed to open files");
    485     return 1;
    486   }
    487 
    488   if (cli_args->priority != '\0') {
    489     if (fprintf(f, "(%c) ", cli_args->priority) < 0) {
    490       fclose(f);
    491       perror("err: failed to write file");
    492       return 1;
    493     }
    494   }
    495 
    496   if (fprintf(f, "%s", cli_args->title) < 0) {
    497     fclose(f);
    498     perror("err: failed to write file");
    499     return 1;
    500   }
    501 
    502   int seen_title = 0;
    503   for (int idx = 1; idx < argc; idx++) {
    504     if (strcmp(argv[idx], "--priority") == 0 || strcmp(argv[idx], "-p") == 0) {
    505       idx++;
    506       continue;
    507     }
    508 
    509     seen_title++;
    510     if (seen_title == 1) {
    511       continue;
    512     }
    513 
    514     if (fprintf(f, " %s", argv[idx]) < 0) {
    515       fclose(f);
    516       perror("err: failed to write file");
    517       return 1;
    518     }
    519   }
    520 
    521   if (fprintf(f, "\n") < 0) {
    522     fclose(f);
    523     perror("err: failed to write file");
    524     return 1;
    525   }
    526 
    527   char id[TASK_ID_CHARS + 1];
    528   if (generate_unique_task_id(id) != 0) {
    529     perror("err: failed to generate unique id");
    530     return 1;
    531   }
    532 
    533   if (fprintf(f, "id: %s\n", id) < 0) {
    534     fclose(f);
    535     perror("err: failed to write file");
    536     return 1;
    537   }
    538 
    539   fclose(f);
    540 
    541   char *editor = getenv("VISUAL");
    542   if (editor != NULL && strcmp(editor, "") == 0) {
    543     editor = NULL;
    544   }
    545   if (editor == NULL) {
    546     editor = getenv("EDITOR");
    547   }
    548 
    549   if (editor == NULL || strcmp(editor, "") == 0) {
    550     return 0;
    551   }
    552 
    553   execlp("sh", "sh", "-c", "exec $1 \"$2\"", "task", editor, path, NULL);
    554   perror("err: failed to open editor");
    555 
    556   return 1;
    557 }
    558 
    559 static int related_commits_command(int argc, char *argv[],
    560                                    struct options *cli_args) {
    561   if (argc != 2) {
    562     fprintf(stderr, "err: task id required\n");
    563     return 1;
    564   }
    565 
    566   if (strcmp(argv[1], "") == 0) {
    567     fprintf(stderr, "err: task id can not be empty\n");
    568     return 1;
    569   }
    570 
    571   char *task_id = argv[1];
    572 
    573   int error;
    574   git_libgit2_init();
    575 
    576   git_repository *repo = NULL;
    577   if ((error = git_repository_open(&repo, ".")) < 0) {
    578     const git_error *e = git_error_last();
    579     fprintf(stderr, "err: %d/%d: %s\n", error, e->klass, e->message);
    580     return 1;
    581   }
    582 
    583   git_object *obj = NULL;
    584   if ((error = git_revparse_single(&obj, repo, "HEAD")) < 0) {
    585     const git_error *e = git_error_last();
    586     fprintf(stderr, "err: %d/%d: %s\n", error, e->klass, e->message);
    587     return 1;
    588   }
    589 
    590   const git_oid *head = NULL;
    591   head = git_object_id(obj);
    592   git_object_free(obj);
    593 
    594   git_revwalk *walker = NULL;
    595   if ((error = git_revwalk_new(&walker, repo)) < 0) {
    596     const git_error *e = git_error_last();
    597     fprintf(stderr, "err: %d/%d: %s\n", error, e->klass, e->message);
    598     return 1;
    599   }
    600 
    601   if ((error = git_revwalk_push(walker, head)) < 0) {
    602     const git_error *e = git_error_last();
    603     fprintf(stderr, "err: %d/%d: %s\n", error, e->klass, e->message);
    604     return 1;
    605   }
    606 
    607   git_oid id;
    608   while (!git_revwalk_next(&id, walker)) {
    609     git_commit *commit;
    610     if ((error = git_commit_lookup(&commit, repo, &id)) < 0) {
    611       const git_error *e = git_error_last();
    612       fprintf(stderr, "err: %d/%d: %s\n", error, e->klass, e->message);
    613       return 1;
    614     }
    615 
    616     const char *summary = git_commit_summary(commit);
    617     const char *message = git_commit_message(commit);
    618 
    619     git_message_trailer_array *trailers =
    620         malloc(sizeof(git_message_trailer_array));
    621     if ((error = git_message_trailers(trailers, message)) < 0) {
    622       const git_error *e = git_error_last();
    623       fprintf(stderr, "err: %d/%d: %s\n", error, e->klass, e->message);
    624       return 1;
    625     }
    626 
    627     for (size_t i = 0; i < trailers->count; i++) {
    628       if (strcmp(trailers->trailers[i].key, "Task-id") == 0) {
    629         if (strcmp(trailers->trailers[i].value, task_id) == 0) {
    630           char oid_str[GIT_OID_HEXSZ + 1];
    631           git_oid_tostr(oid_str, sizeof(oid_str), &id);
    632           printf("%s: %s\n", oid_str, summary);
    633         }
    634       }
    635     }
    636   }
    637 
    638   git_libgit2_shutdown();
    639 
    640   return 0;
    641 }
    642 
    643 int main(int argc, char *argv[]) {
    644   struct options cli_args = {0};
    645   cli_args.priority = '\0';
    646 
    647   if (argc > 1 &&
    648       (strcmp(argv[1], "--help") == 0 || strcmp(argv[1], "-h") == 0)) {
    649     print_help(argv[0]);
    650     return 0;
    651   }
    652 
    653   if (argc > 1 && strcmp(argv[1], "new") == 0) {
    654     if (new_command(argc - 1, argv + 1, &cli_args) != 0) {
    655       return 1;
    656     }
    657   }
    658 
    659   if (argc > 1 && strcmp(argv[1], "commits") == 0) {
    660     if (related_commits_command(argc - 1, argv + 1, &cli_args) != 0) {
    661       return 1;
    662     }
    663   }
    664 
    665   if (argc > 1 && strcmp(argv[1], "summary") == 0) {
    666     int open_tasks;
    667     if ((open_tasks = count_files_scandir(TASKS_PATH)) < 0) {
    668       perror("err: failed to read tasks/");
    669       return 1;
    670     }
    671 
    672     int closed_tasks;
    673     if ((closed_tasks = count_files_scandir(DONE_TASKS_PATH)) < 0) {
    674       if (errno != ENOENT) {
    675         perror("err: failed to read tasks/done/");
    676         return 1;
    677       }
    678       closed_tasks = 0;
    679     }
    680 
    681     printf("open tasks: %d\n", open_tasks);
    682     printf("closed tasks: %d\n", closed_tasks);
    683     printf("\ncontexts:\n");
    684     print_properties('@');
    685     printf("\nprojects:\n");
    686     print_properties('+');
    687   }
    688 
    689   return 0;
    690 }