← thecodex.expert · The Codex Family of Knowledge
Tier 1 · Beginner · C Project

To-Do List (CLI)

A to-do list that remembers your tasks between runs, using nothing but a plain text file — no database, no library. You'll design your own tiny file format and the logic to read and write it correctly.

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

1 The Problem

We want a to-do list you can actually use: add tasks, see them numbered, remove the ones you finish, and — crucially — have them saved to a file so they survive after you close the program. It teaches lists, a menu loop, and saving data to disk.

Where this shows up: every app that stores your stuff — notes, reminders, shopping lists, saved games, settings. The pattern of “keep a list in memory, save it to a file, load it back next time” is the simplest form of a database.

2 How to Think About It

Every subcommand does the same three things in order: load the current tasks, change them somehow, save them back. Getting the ID scheme right up front avoids a whole category of bugs later.

The plan — in plain English
1. Load existing tasks from the save file (or start empty if it doesn't exist yet). → 2. Apply one command — add, list, done, or remove. → 3. Save the updated list back to the same file. → Each task keeps a permanent ID that is never reused, even after that task is removed.

Add

View

Remove

Quit

Load tasks from file

Show menu

Choice?

Add task

Show tasks

Remove task

Stop

Save to file

3 The Build — explained part by part

A fixed-size Task array stands in for a real dynamic list (C has none built in), and a small pipe-delimited text format (id|done|text per line) stands in for a database.

CTodoList.h / TodoList.c / main.c
#ifndef TODO_LIST_H
#define TODO_LIST_H

#define MAX_TASKS 256
#define MAX_TEXT 128

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

/* Loads tasks from a pipe-delimited file (id|done|text per line). Returns
 * the number of tasks loaded, or 0 if the file doesn't exist. */
int load_tasks(const char *path, Task *tasks, int max_tasks);

/* Writes `count` tasks back to `path` in the same pipe-delimited format. */
void save_tasks(const char *path, const Task *tasks, int count);

/* One more than the highest id ever assigned — never reused, even after a delete. */
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. Returns 1 if found, 0 otherwise. */
int mark_done(Task *tasks, int count, int id);

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

#endif

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

int load_tasks(const char *path, Task *tasks, int max_tasks) {
    FILE *f = fopen(path, "r");
    if (!f) return 0;
    int count = 0;
    char line[300];
    while (count < max_tasks && fgets(line, sizeof(line), f)) {
        int id, done;
        char text[MAX_TEXT] = {0};
        /* format: id|done|text\n */
        char *p1 = strchr(line, '|');
        if (!p1) continue;
        char *p2 = strchr(p1 + 1, '|');
        if (!p2) continue;
        *p1 = '\0'; *p2 = '\0';
        id = atoi(line);
        done = atoi(p1 + 1);
        strncpy(text, p2 + 1, sizeof(text) - 1);
        size_t tl = strlen(text);
        if (tl > 0 && text[tl - 1] == '\n') text[tl - 1] = '\0';
        tasks[count].id = id;
        tasks[count].done = done;
        strncpy(tasks[count].text, text, sizeof(tasks[count].text) - 1);
        count++;
    }
    fclose(f);
    return count;
}

void save_tasks(const char *path, const Task *tasks, int count) {
    FILE *f = fopen(path, "w");
    if (!f) return;
    for (int i = 0; i < count; i++) {
        fprintf(f, "%d|%d|%s\n", tasks[i].id, tasks[i].done, tasks[i].text);
    }
    fclose(f);
}

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;
}

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

int main(int argc, char **argv) {
    static Task tasks[MAX_TASKS];
    const char *file = "tasks.db";
    int count = load_tasks(file, tasks, MAX_TASKS);

    if (argc < 2) { fprintf(stderr, "Usage: %s add|list|done|remove ...\n", argv[0]); return 1; }

    if (strcmp(argv[1], "add") == 0 && argc >= 3) {
        count = add_task(tasks, count, argv[2]);
        printf("Added task #%d.\n", tasks[count - 1].id);
    } else if (strcmp(argv[1], "list") == 0) {
        for (int i = 0; i < count; i++)
            printf("#%d [%c] %s\n", tasks[i].id, tasks[i].done ? 'x' : ' ', tasks[i].text);
    } else if (strcmp(argv[1], "done") == 0 && argc >= 3) {
        int id = atoi(argv[2]);
        printf(mark_done(tasks, count, id) ? "Completed #%d.\n" : "No task #%d.\n", id);
    } else if (strcmp(argv[1], "remove") == 0 && argc >= 3) {
        int id = atoi(argv[2]);
        int before = count;
        count = remove_task(tasks, count, id);
        printf(count < before ? "Removed #%d.\n" : "No task #%d.\n", id);
    }
    save_tasks(file, 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
static Task tasks[MAX_TASKS] — a fixed-capacity array on the stack (well, static storage, since it’s large) stands in for what a growable ArrayList or Vec would be in another language. C requires you to decide a maximum size up front, which is disclosed here as a genuine limitation, not hidden.

next_id scans for the highest existing ID and adds one — this is what guarantees an ID is never reused even after its task is deleted, matching the same rule this project uses in every other language on this site. Using array position as the ID instead would silently reassign a deleted task’s number to a completely different task.

The pipe-delimited format and strchr — splitting a line on the first two | characters with strchr is a genuinely simple, real way to design a tiny file format without pulling in a parsing library. Its real limitation: a task’s own text can never contain a | character, since that would be misread as a field separator — a limitation a JSON-based format (see the course’s File I/O lesson) would not have.
Common mistakes — and how to avoid them
✗ Using the array index as the task’s ID instead of storing a real, permanent ID field — removing a task would then silently renumber every task after it.
✓ Store id as its own field in the Task struct, computed once by next_id and never recalculated from position.
✗ Not trimming the trailing newline from a loaded line before storing it as the task text — every reloaded task would have a stray newline baked in.
✓ load_tasks explicitly checks for and strips a trailing \n.
✗ Letting a task’s text contain a literal | character — it would be misread as a field separator on the next load.
✓ This is a genuine, disclosed limitation of the simple format used here; a real project would escape the delimiter or switch to a format like JSON that doesn’t need one.

4 Test & Prove Each Part

C has no built-in test framework and this sandbox can't reach a package registry for one, so these tests use plain assert() calls, including one real round-trip test through an actual temporary file on disk.

Adding tasks assigns sequential IDs starting at 1
The next ID is always one more than the highest ID ever assigned, even after a gap
Marking a task done finds it by ID, not by its position in the array
Removing a task by ID drops exactly that one task and shifts nothing incorrectly
Saving and reloading through a real file preserves every task exactly
Ctest_TodoList.c
#include "TodoList.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

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

static void adding_assigns_sequential_ids_starting_at_one(void) {
    Task tasks[MAX_TASKS];
    int count = 0;
    count = add_task(tasks, count, "milk");
    count = add_task(tasks, count, "eggs");
    assert(count == 2);
    assert(tasks[0].id == 1);
    assert(tasks[1].id == 2);
}

static void next_id_never_reuses_a_deleted_number(void) {
    Task tasks[MAX_TASKS] = { {1, 0, "a"}, {3, 0, "b"} };
    assert(next_id(tasks, 2) == 4);
    assert(next_id(tasks, 0) == 1);
}

static void mark_done_finds_by_id_not_position(void) {
    Task tasks[MAX_TASKS] = { {1, 0, "a"}, {2, 0, "b"} };
    assert(mark_done(tasks, 2, 2) == 1);
    assert(tasks[1].done == 1);
    assert(tasks[0].done == 0);
    assert(mark_done(tasks, 2, 99) == 0);
}

static void remove_drops_exactly_one_task_by_id(void) {
    Task tasks[MAX_TASKS] = { {1, 0, "a"}, {2, 0, "b"}, {3, 0, "c"} };
    int count = remove_task(tasks, 3, 2);
    assert(count == 2);
    assert(tasks[0].id == 1);
    assert(tasks[1].id == 3);
}

static void save_then_load_round_trips_through_a_real_file(void) {
    Task tasks[MAX_TASKS] = { {1, 1, "one"}, {2, 0, "two words"} };
    const char *path = "/tmp/todo_test_XXXXXX.db";
    char tmp[64];
    strncpy(tmp, path, sizeof(tmp));
    save_tasks(tmp, tasks, 2);
    Task loaded[MAX_TASKS];
    int n = load_tasks(tmp, loaded, MAX_TASKS);
    assert(n == 2);
    assert(loaded[0].id == 1 && loaded[0].done == 1 && strcmp(loaded[0].text, "one") == 0);
    assert(loaded[1].id == 2 && loaded[1].done == 0 && strcmp(loaded[1].text, "two words") == 0);
    remove(tmp);
}

int main(void) {
    RUN(adding_assigns_sequential_ids_starting_at_one);
    RUN(next_id_never_reuses_a_deleted_number);
    RUN(mark_done_finds_by_id_not_position);
    RUN(remove_drops_exactly_one_task_by_id);
    RUN(save_then_load_round_trips_through_a_real_file);
    printf("All tests passed.\n");
    return 0;
}

Compile and run with gcc -o test_run TodoList.c test_TodoList.c && ./test_run. One test genuinely writes to and reads from /tmp, then deletes the file it created.

5 The Interface

Even a tiny program has an interface. Here is its contract, documented plainly.

INPUTCommandadd <text> | list | done <id> | remove <id>
What it expects
./todo add "buy milk"
OUTPUTResulta confirmation line, or the current list
What it returns
Added task #1.

6 Run It & Automate It

Save the code as TodoList.h / TodoList.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 todo main.c TodoList.c && ./todo add "buy milk" && ./todo list
Each run reads tasks.db from the current directory, changes it, and saves it back — so state persists 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
$ ./todo add "buy milk"
Added task #1.
$ ./todo add "walk the dog"
Added task #2.
$ ./todo done 1
Completed #1.
$ ./todo list
#1 [x] buy milk
#2 [ ] walk the dog
If it breaks — how to fix it
🚨 Tasks disappear between runs.
Check that save_tasks is actually called at the end of main, and that the program has write permission in the current directory.
🚨 An old task comes back after being removed.
Make sure you're running the freshly recompiled binary — a stale tasks.db from an earlier version of the program can also cause this if the file format changed.
🚨 Weird characters or a missing task after editing tasks.db by hand.
The pipe-delimited format has no escaping: a stray | typed into a task's text, or a manually broken line, will confuse load_tasks. Stick to the CLI rather than hand-editing the file.
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 extending it
Switch the save format to one line of JSON per task so text can safely contain any character, including |. Or add a priority field and a command to list tasks sorted by it.
What you learned
Designing a tiny, honest file format and its real limitations; why storing a permanent ID field beats using array position; fixed-size arrays as C's stand-in for a growable list; and testing real file I/O through an actual temporary file rather than mocking it away.