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.
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.
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.
#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;
}
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.
id as its own field in the Task struct, computed once by next_id and never recalculated from position.load_tasks explicitly checks for and strips a trailing \n.| character — it would be misread as a field separator on the next load.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.
#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.
What it expects
./todo add "buy milk"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.
gcc -o todo main.c TodoList.c && ./todo add "buy milk" && ./todo listEach 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.
$ ./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 dogsave_tasks is actually called at the end of main, and that the program has write permission in the current directory.tasks.db from an earlier version of the program can also cause this if the file format changed.| 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.// 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.' }
}
}
|. Or add a priority field and a command to list tasks sorted by it.