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.
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.
argv: add, list, or done. → 3. Apply it, generating a fresh, never-reused ID for a new task. → 4. Save before exiting.
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.
#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;
}
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.
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.next_id does, so removed IDs are never recycled.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.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.
#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
What it expects
add Ship the release
done 1
listWhat it returns
#1 [x] Ship the release
#2 [ ] Write the docs6 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.
gcc -o tasks main.c CliTaskManager.c && ./tasks add Ship the releaseTasks 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.
$ ./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 docslist 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.// 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.' }
}
}
- Add a remove command. Delete a task by ID without renumbering the rest. (Teaches: array-shifting removal, already written as
remove_task.) - Grow the array dynamically. Replace the fixed
MAX_TASKScap with amalloc/realloc-backed array that grows as needed. (Teaches: manual dynamic arrays.) - Add priorities. Sort
listoutput by a priority field usingqsort. (Teaches: comparator functions, as log-analyser uses for its top-N ranking.)
argv subcommand dispatch looks like in a language with no built-in CLI parser at all. Related: Structs and Arrays, File I/O.