← thecodex.expert · The Codex Family of Knowledge
Tier 3 · Upper-Intermediate · C Project

CLI Task Manager

A sturdier command-line task manager with permanent, never-reused task IDs. Teaches manual argv parsing, ID management, and file persistence in C.

🧠 Teaches how to think spoonfed, every age Last verified:

1 The Problem

We want a real command-line tool: tasks add "Buy milk", tasks list, tasks done 2 — commands and arguments, just like git or npm. It teaches argparse, the proper way to build CLI tools that feel professional, not like a toy menu.

Where this shows up: every developer tool — git, docker, npm, pip. Building proper CLIs with subcommands and arguments is a core skill for automation, dev tools, and scripts others will use.

2 How to Think About It

The one design decision that matters here: tasks are identified by a permanent ID, not by their position in the array, so removing a task never changes what a saved ID points to.

The plan — in plain English
1. Load tasks (each with an ID) from disk into a fixed-size array. → 2. Read the subcommand from argv: add, list, or done. → 3. Apply it, generating a fresh, never-reused ID for a new task. → 4. Save before exiting.

add

list

done

Command line input

argparse parses it

Which subcommand?

Add task

Show tasks

Mark task done

Save to file

3 The Build — explained part by part

Here is the complete manager, split into a header, the testable logic, and a tiny main.c that wires it to real argv. A real project might reach for getopt for flag-style options, but C has no built-in subcommand parser (nothing like Rust’s clap or Python’s argparse) — comparing argv[1] against each subcommand by hand is the normal C way to do this.

CCliTaskManager.h / CliTaskManager.c / main.c
#ifndef CLI_TASK_MANAGER_H
#define CLI_TASK_MANAGER_H

#define MAX_TASKS 256
#define MAX_TEXT 128

typedef struct {
    int id;
    int done;
    char text[MAX_TEXT];
} Task;

/* One more than the highest id ever assigned -- never reused, even after a
 * task is removed, the same guarantee a real database's auto-increment
 * primary key gives you. */
int next_id(const Task *tasks, int count);

/* Appends a new, not-done task with the next available id. Returns the new count. */
int add_task(Task *tasks, int count, const char *text);

/* Marks the task with the given id done, searching by id (not array
 * position). Returns 1 if found, 0 otherwise. */
int mark_done(Task *tasks, int count, int id);

/* Removes the task with the given id, shifting later tasks down. Returns
 * the new count (unchanged if the id was not found). */
int remove_task(Task *tasks, int count, int id);

/* Renders "#id [x] text" (or "[ ]" if not done) one per line into out,
 * truncating safely at out_cap. */
void format_list(const Task *tasks, int count, char *out, int out_cap);

/* Hand-rolled "id|done|text" line format -- one task per line. */
int serialize(const Task *tasks, int count, char *out, int out_cap);
int deserialize(const char *data, Task *tasks, int max_tasks);

/* Loads/saves tasks from/to a real file using the format above. load_tasks
 * returns 0 (an empty task list) if the file does not exist yet. */
int load_tasks(const char *path, Task *tasks, int max_tasks);
void save_tasks(const char *path, const Task *tasks, int count);

#endif

#include "CliTaskManager.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

int next_id(const Task *tasks, int count) {
    int max_id = 0;
    for (int i = 0; i < count; i++) if (tasks[i].id > max_id) max_id = tasks[i].id;
    return max_id + 1;
}

int add_task(Task *tasks, int count, const char *text) {
    tasks[count].id = next_id(tasks, count);
    tasks[count].done = 0;
    strncpy(tasks[count].text, text, sizeof(tasks[count].text) - 1);
    tasks[count].text[sizeof(tasks[count].text) - 1] = '\0';
    return count + 1;
}

int mark_done(Task *tasks, int count, int id) {
    for (int i = 0; i < count; i++) {
        if (tasks[i].id == id) { tasks[i].done = 1; return 1; }
    }
    return 0;
}

int remove_task(Task *tasks, int count, int id) {
    for (int i = 0; i < count; i++) {
        if (tasks[i].id == id) {
            for (int j = i; j < count - 1; j++) tasks[j] = tasks[j + 1];
            return count - 1;
        }
    }
    return count;
}

void format_list(const Task *tasks, int count, char *out, int out_cap) {
    out[0] = '\0';
    char line[MAX_TEXT + 32];
    for (int i = 0; i < count; i++) {
        snprintf(line, sizeof(line), "#%d [%s] %s\n", tasks[i].id, tasks[i].done ? "x" : " ", tasks[i].text);
        size_t room = (size_t)(out_cap - (int)strlen(out) - 1);
        strncat(out, line, room);
    }
}

int serialize(const Task *tasks, int count, char *out, int out_cap) {
    out[0] = '\0';
    char line[MAX_TEXT + 32];
    for (int i = 0; i < count; i++) {
        snprintf(line, sizeof(line), "%d|%d|%s\n", tasks[i].id, tasks[i].done, tasks[i].text);
        size_t room = (size_t)(out_cap - (int)strlen(out) - 1);
        strncat(out, line, room);
    }
    return (int)strlen(out);
}

int deserialize(const char *data, Task *tasks, int max_tasks) {
    int count = 0;
    const char *line_start = data;
    while (*line_start && count < max_tasks) {
        const char *line_end = strchr(line_start, '\n');
        size_t line_len = line_end ? (size_t)(line_end - line_start) : strlen(line_start);
        if (line_len == 0) { if (!line_end) break; line_start = line_end + 1; continue; }

        char line[300];
        size_t copy_len = line_len < sizeof(line) - 1 ? line_len : sizeof(line) - 1;
        memcpy(line, line_start, copy_len);
        line[copy_len] = '\0';

        char *p1 = strchr(line, '|');
        if (p1) {
            char *p2 = strchr(p1 + 1, '|');
            if (p2) {
                *p1 = '\0'; *p2 = '\0';
                tasks[count].id = atoi(line);
                tasks[count].done = atoi(p1 + 1);
                strncpy(tasks[count].text, p2 + 1, sizeof(tasks[count].text) - 1);
                tasks[count].text[sizeof(tasks[count].text) - 1] = '\0';
                count++;
            }
        }
        if (!line_end) break;
        line_start = line_end + 1;
    }
    return count;
}

int load_tasks(const char *path, Task *tasks, int max_tasks) {
    FILE *f = fopen(path, "r");
    if (!f) return 0;
    fseek(f, 0, SEEK_END);
    long size = ftell(f);
    fseek(f, 0, SEEK_SET);
    char *buf = malloc((size_t)size + 1);
    if (!buf) { fclose(f); return 0; }
    size_t read_len = fread(buf, 1, (size_t)size, f);
    buf[read_len] = '\0';
    fclose(f);
    int count = deserialize(buf, tasks, max_tasks);
    free(buf);
    return count;
}

void save_tasks(const char *path, const Task *tasks, int count) {
    char buf[MAX_TASKS * (MAX_TEXT + 32)];
    serialize(tasks, count, buf, sizeof(buf));
    FILE *f = fopen(path, "w");
    if (!f) return;
    fputs(buf, f);
    fclose(f);
}

#include "CliTaskManager.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

#define FILE_PATH "tasks.db"

static void print_usage(void) {
    fprintf(stderr, "Usage: cli_task_manager <add|list|done> [args]\n");
    fprintf(stderr, "  add <text...>   add a new task\n");
    fprintf(stderr, "  list            show all tasks\n");
    fprintf(stderr, "  done <id>       mark task <id> done\n");
}

int main(int argc, char **argv) {
    static Task tasks[MAX_TASKS];
    int count = load_tasks(FILE_PATH, tasks, MAX_TASKS);

    if (argc < 2) {
        print_usage();
        return 1;
    }

    if (strcmp(argv[1], "add") == 0) {
        if (argc < 3) { print_usage(); return 1; }
        char text[MAX_TEXT] = {0};
        for (int i = 2; i < argc; i++) {
            strncat(text, argv[i], sizeof(text) - strlen(text) - 1);
            if (i + 1 < argc) strncat(text, " ", sizeof(text) - strlen(text) - 1);
        }
        count = add_task(tasks, count, text);
        printf("Added #%d: %s\n", tasks[count - 1].id, text);
    } else if (strcmp(argv[1], "list") == 0) {
        char out[MAX_TASKS * (MAX_TEXT + 32)];
        format_list(tasks, count, out, sizeof(out));
        fputs(out, stdout);
    } else if (strcmp(argv[1], "done") == 0) {
        if (argc < 3) { print_usage(); return 1; }
        int id = atoi(argv[2]);
        if (mark_done(tasks, count, id)) {
            printf("Marked #%d done.\n", id);
        } else {
            printf("No task #%d.\n", id);
        }
    } else {
        print_usage();
        return 1;
    }

    save_tasks(FILE_PATH, tasks, count);
    return 0;
}
⚠ No in-browser playground here
C compiles to a real, native binary, so unlike the Python version of this project there is no editor above you can run in the browser. Copy the code below and run it on your own machine — it takes seconds once GCC or Clang is installed.
What each part does — in plain words
int next_id(const Task *tasks, int count) — finds the highest existing ID and adds one, so IDs only ever go up, even after a task with a high ID is removed. This mirrors what a real database’s auto-increment primary key guarantees, and it is the reason this project is sturdier than the basic to-do-list, which (like this one) identifies tasks by ID rather than array position, but here that guarantee is the whole point of the project and is directly tested.

static Task tasks[MAX_TASKS] in main — a fixed-size array on the stack (made static so a 256-task array does not itself blow the stack), not a dynamically grown list. C has no built-in growable array like Rust’s Vec or Java’s ArrayList; a fixed cap chosen up front is the simplest honest alternative to writing your own dynamic-array realloc logic, at the cost of a hard task-count ceiling.

remove_task — shifts every later task down by one array slot with a hand-written loop, since there is no Vec::retain to do it for you. The task’s ID moves with it, so positions change but identities never do.

if (strcmp(argv[1], "add") == 0) { ... } else if (...) { ... } — the manual subcommand dispatcher. Every argument comparison is a plain strcmp, which is exactly what a CLI-parsing library is automating underneath in any language.
Common mistakes — and how to avoid them
✗ Computing the next ID as count + 1 instead of scanning for the true maximum — this reuses IDs the moment any task has ever been removed, since the count shrinks back down.
✓ Take the maximum existing ID and add one, as next_id does, so removed IDs are never recycled.
✗ Declaring Task tasks[MAX_TASKS] as a plain local variable inside main — at 256 tasks × 128 bytes of text each, that is over 32KB on the stack, which is a real crash risk on a thread with a small stack size.
✓ Mark it static (as this project does) to place it in the program’s data segment instead of the stack, or malloc it on the heap.

4 Test & Prove Each Part

We test the ID-management guarantee directly, since it is the whole point of this project over the basic to-do-list, plus a real round trip through a file on disk.

IDs count up and are never reused, even after a task is removed
Marking a task done finds it by ID, not by its array position
A list of tasks survives a round trip through serialize and deserialize
Adding to an empty list starts at ID 1
Tasks survive a real save and load through a file in /tmp
format_list marks a done task with [x] and a pending one with [ ]
Ctest_CliTaskManager.c
#include "CliTaskManager.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>

#define RUN(name) do { name(); printf("PASS: %s\n", #name); } while (0)

static void ids_count_up_and_are_never_reused(void) {
    Task tasks[MAX_TASKS];
    int count = 0;
    count = add_task(tasks, count, "first");
    int id1 = tasks[count - 1].id;
    count = add_task(tasks, count, "second");
    int id2 = tasks[count - 1].id;
    assert(id1 == 1);
    assert(id2 == 2);
    count = remove_task(tasks, count, id1); /* simulate removing task 1 */
    count = add_task(tasks, count, "third");
    int id3 = tasks[count - 1].id;
    assert(id3 == 3); /* not reused as 1 */
}

static void mark_done_finds_by_id_not_position(void) {
    Task tasks[2] = { {5, 0, "A"}, {9, 0, "B"} };
    assert(mark_done(tasks, 2, 9));
    assert(tasks[1].done);
    assert(!tasks[0].done);
    assert(!mark_done(tasks, 2, 999));
}

static void round_trips_through_serialize_and_deserialize(void) {
    Task tasks[2] = { {1, 1, "Ship the release"}, {2, 0, "Write the docs"} };
    char buf[512];
    serialize(tasks, 2, buf, sizeof(buf));

    Task back[MAX_TASKS];
    int n = deserialize(buf, back, MAX_TASKS);
    assert(n == 2);
    assert(back[0].id == 1 && back[0].done == 1);
    assert(strcmp(back[0].text, "Ship the release") == 0);
    assert(back[1].id == 2 && back[1].done == 0);
    assert(strcmp(back[1].text, "Write the docs") == 0);
}

static void adding_when_empty_starts_at_one(void) {
    Task tasks[MAX_TASKS];
    int count = add_task(tasks, 0, "only task");
    assert(count == 1);
    assert(tasks[0].id == 1);
}

static void saves_and_loads_through_a_real_file(void) {
    Task tasks[2] = { {1, 0, "Buy milk"}, {2, 1, "Walk the dog"} };
    const char *path = "/tmp/cli_task_manager_test.db";
    save_tasks(path, tasks, 2);

    Task loaded[MAX_TASKS];
    int n = load_tasks(path, loaded, MAX_TASKS);
    assert(n == 2);
    assert(strcmp(loaded[0].text, "Buy milk") == 0);
    assert(loaded[1].done == 1);
    remove(path);
}

static void format_list_marks_done_with_x_and_pending_with_space(void) {
    Task tasks[2] = { {1, 1, "done task"}, {2, 0, "pending task"} };
    char out[512];
    format_list(tasks, 2, out, sizeof(out));
    assert(strstr(out, "#1 [x] done task") != NULL);
    assert(strstr(out, "#2 [ ] pending task") != NULL);
}

int main(void) {
    RUN(ids_count_up_and_are_never_reused);
    RUN(mark_done_finds_by_id_not_position);
    RUN(round_trips_through_serialize_and_deserialize);
    RUN(adding_when_empty_starts_at_one);
    RUN(saves_and_loads_through_a_real_file);
    RUN(format_list_marks_done_with_x_and_pending_with_space);
    printf("All tests passed.\n");
    return 0;
}

Compile and run with gcc -std=c17 -Wall -Wextra -Wpedantic -o test_run CliTaskManager.c test_CliTaskManager.c && ./test_run. The ID-reuse test is the one that actually proves the design decision: it adds two tasks, removes the first, adds a third, and asserts the third gets ID 3 — not 1. main.c is left out of this compile line since it has its own main.

5 The Interface

INPUTINPUTcommand-line arguments
What it expects
add Ship the release
done 1
list
OUTPUTOUTPUTtask list / confirmation
What it returns
#1 [x] Ship the release
#2 [ ] Write the docs

6 Run It & Automate It

Save the code as CliTaskManager.h / CliTaskManager.c / main.c and compile it with gcc — that turns your source directly into a native executable for your machine. No separate runtime needed: the compiled binary runs on its own.

Run it locally
gcc -o tasks main.c CliTaskManager.c && ./tasks add Ship the release
Tasks persist in tasks.db in the current directory between runs.

A CI tool like Jenkins runs the same compile-then-test-then-check-for-leaks steps automatically whenever the code changes — every line below has a plain explanation.

What you should see when it works
Terminala real run
$ ./tasks add Ship the release
Added #1: Ship the release
$ ./tasks add Write the docs
Added #2: Write the docs
$ ./tasks done 1
Marked #1 done.
$ ./tasks list
#1 [x] Ship the release
#2 [ ] Write the docs
If it breaks — how to fix it
🚨 Usage: cli_task_manager <add|list|done> [args]
Printed whenever the first argument is missing or not recognized. Check for typos in the subcommand.
🚨 No task #7.
The ID does not exist — run list first to see the real IDs currently in use; they are not necessarily 1, 2, 3 if any tasks have been removed by hand-editing tasks.db.
GroovyJenkinsfile
// Jenkinsfile — compiles, tests, and checks for leaks on every change.
pipeline {
    agent any

    stages {
        stage('Get the code') {
            // download the latest code
            steps { checkout scm }
        }
        stage('Compile') {
            steps {
                // confirm a compiler is installed
                sh 'gcc --version'
                // compile with strict warnings on
                sh 'gcc -std=c17 -Wall -Wextra -o app *.c'
            }
        }
        stage('Run the tests') {
            steps {
                // prints PASS/FAIL, exits non-zero on failure
                sh './app'
            }
        }
        stage('Check for memory leaks') {
            steps {
                // fails the build on any leak or invalid access
                sh 'valgrind --error-exitcode=1 --leak-check=full ./app'
            }
        }
    }

    post {
        success { echo 'All tests passed, no leaks found.' }
        failure { echo 'A test or Valgrind check failed — see above.' }
    }
}
🎯 Try this next — make it yours
  1. Add a remove command. Delete a task by ID without renumbering the rest. (Teaches: array-shifting removal, already written as remove_task.)
  2. Grow the array dynamically. Replace the fixed MAX_TASKS cap with a malloc/realloc-backed array that grows as needed. (Teaches: manual dynamic arrays.)
  3. Add priorities. Sort list output by a priority field using qsort. (Teaches: comparator functions, as log-analyser uses for its top-N ranking.)
What you learned
You learned why identity-by-position breaks under mutation, and how to give records a permanent, never-reused ID by hand — the same problem a database’s auto-increment column solves. You also saw what manual argv subcommand dispatch looks like in a language with no built-in CLI parser at all. Related: Structs and Arrays, File I/O.