Skip to content

ToDoist: Terminal Task Manager in C

in-progress

Event-driven ncurses interface, transactional SQLite writes, and hand-managed memory that runs clean under ASan and Valgrind.

A terminal task manager in C, with a domain simple enough to stay out of the way while I learn systems programming.

C11ncursesSQLitelibuuidGNU MakeGitHub Actions
View on GitHub

A keyboard-driven task manager that lives entirely in the terminal with zero dependencies beyond libc, ncurses, libsqlite3, and libuuid.

The implementation is C11 across four translation units, and most of it lives in src/tui.c: multi-panel navigation, form interactions, dynamic resizing traps, and the ncurses lifecycle.

Memory Ownership & The free(ptr - 4) Bug

In high-level languages, strings carry lengths and garbage collectors clean up unreferenced memory. In C, memory ownership dictates architecture.

ncurses enforces strict ownership constraints: when instantiating a menu item with new_item(name, description), the library stores raw pointers directly (item->name.str = name) without copying strings. The string rendered on screen and the field inside the backing Task struct share the exact same allocation. Freeing or mutating the struct while the menu is posted leaves dangling pointers that corrupt memory on the next redraw.

To eliminate redundant allocator overhead, status checkboxes and task titles are fused into a single heap buffer:

  1. Task.title is allocated with malloc(mark_len + title_len + 1), reserving four bytes for the "[ ] " or "[X] " prefix alongside the string and null terminator.
  2. new_item() takes this base pointer directly, allowing ncurses to render the checkbox and text as a single label without secondary string formatting allocations.

The tradeoff surfaces during edits and writes: an input form or SQL update must receive Buy milk, not [ ] Buy milk.

Early in development, the codebase attempted to handle this by mutating the struct pointer in-place with task->title = task->title + 4, followed by a fragile attempt to recover the original base address using free(task->title - 4).

// OLD CODE: Destructive pointer arithmetic
task->title = task->title + 4; 
...
free(task->title - 4); // Fragile attempt to recover original allocation base

When this brittle pointer arithmetic led to corrupted heaps and double-frees, it forced a strict discipline: an owning pointer has exactly one value across its entire lifecycle. Non-owning views are now held in separate const char * variables (displayed_title = task->title + 4 in forms, clean_title = task->title + 4 before sqlite3_bind_text) that are never passed to free(). In addition, free_task_field() explicitly zeroes title, desc, and tag pointers to NULL to guard against double-free hazards across rebuild cycles.

TUI Mechanics & Buffer Sanitization

The layout requires at least 70 columns by 27 rows. Rather than rendering garbled components when a terminal is shrunk below threshold, handle_resize() traps execution in a quarantine loop displaying a centered warning banner until a subsequent KEY_RESIZE event signals that dimensions have returned to safe bounds.

Screen inputs are bridged to storage via trim_fieldbuf() in src/utils.c:

  • ncurses form fields pad user input with trailing whitespace out to the full field width (50 spaces for titles, 30 for tags).
  • Without sanitization, empty fields register as non-empty (strlen > 0), and saved records accumulate excessive whitespace.
  • trim_fieldbuf() strips leading and trailing whitespace, carriage returns, and newlines before allocating an exact malloc(len + 1) buffer for SQLite persistence.

Storage & Transactional Boundaries

Persistence runs on SQLite across three tables: tasks, tags, and a task_tags join table using composite primary keys. UUIDs are generated via libuuid and stored as 36-byte text strings.

(A notable early bug involved using sqlite3_bind_text16 instead of sqlite3_bind_text on the ASCII UUID buffer. SQLite interpreted 1-byte ASCII characters as 2-byte UTF-16 code units, writing CJK/Chinese characters like 摤㠷... directly into the database).

  • Soft Deletes & Foreign Keys: Records are never dropped via DELETE. Deletes execute as UPDATE setting is_deleted = 1. While the schema defines ON DELETE CASCADE, PRAGMA foreign_keys = ON; is never issued during db_open(), leaving cascading constraints disabled by default in SQLite.
  • Two-Pass Allocation: load_tasks_fillterd() queries SQLite twice: first with SELECT COUNT(DISTINCT t.id) to allocate an exact Task[] array with calloc(), followed by the fetch query. If sqlite3_step() fails mid-stream, an error cleanup loop calls free_task_field() on every previously populated element to prevent partial-fetch leaks.
  • Transaction Rollbacks: Inserting a task alongside its tags runs within a single SQLite transaction, with an explicit rollback on every failure path. One of those paths has a logic flaw: when an existing tag is resolved (rc == SQLITE_ROW), checking if (rc != SQLITE_DONE) triggers an unintended rollback on existing tags. In contrast, update_task() sends every error site through one goto rollback; exit.

View Invalidation & Teardown Order

Rather than calculating visual diffs, every mutating keystroke tears down the active task menu and re-executes a clean query against SQLite. That covers toggling a task, modifying a tag, and updating an entry.

EVERY WRITE REBUILDS THE WHOLE VIEW wgetch() focused window handler toggle, delete, add, edit update_task(db) BEGIN, then COMMIT or ROLLBACK refresh_tasks_view() no diffing, ever Teardown 1. remember item_index(current_item()) so the cursor survives 2. unpost_menu(), then set_menu_items(menu, NULL) 3. free_item() on every ITEM 4. free(items) 5. free_task_field() on every Task, then free(task_list) Order is load bearing Step 3 must come before step 5. Every ITEM label points into a string that step 5 frees. Reverse them and ncurses reads freed memory on the next redraw, which is the kind of bug that does not crash where it happened. Rebuild 6. load_tasks_fillterd(db, &data, current_filter): a fresh query 7. new_item() and set_item_userptr() per row, then post_menu() 8. set_current_item(items[saved_index]), update the details pane, doupdate() For a list that fits on one screen this is the right trade. The cost is one SQLite read per keystroke that writes. The gain is exactly one code path that can put a task on screen, so the display can never drift from the database.

Because ITEM references hold raw pointers into strings owned by Task structs, teardown order is load-bearing:

  1. item_index(current_item()) captures the active cursor index.
  2. unpost_menu() detaches the menu from the screen.
  3. set_menu_items(menu, NULL) disconnects the item array; ncurses rejects freeing items that remain bound to a menu.
  4. Individual ITEM handles are released via free_item().
  5. The items pointer array is freed.
  6. free_task_field() deallocates string fields before free(task_list) releases the struct array.
  7. load_tasks_fillterd() re-queries the database, builds fresh items, posts the menu, and restores the saved cursor index.

Query Splicing & Known Limits

Filter states are expressed as raw SQL fragments spliced directly into query WHERE clauses (1=1, t.status = 1, or date-bounded ranges). This design creates three runtime edge cases:

  1. Tag Filter Truncation: Tag filters allocate a 31-byte heap buffer via malloc(31) and format the clause with snprintf(..., 31, "tg.name='%s'", tag_name). Because the SQL boilerplate consumes 11 bytes, tags exceeding 20 characters lose their closing quote ('), producing malformed SQL that breaks the query outright. Unescaped apostrophes inside tag names similarly corrupt query syntax.
  2. Timezone Offset Drift: Filters evaluate date(due_date, 'unixepoch') against date('now') directly in SQLite. Because SQLite calculates date('now') in UTC while the TUI formats display timestamps using local system time (localtime()), users in positive UTC offsets experience a desynchronization window after midnight where tasks display yesterday’s dates.
  3. Empty State Inference: Empty state labels (“No tasks for today”) are determined by running strstr() searches against the active SQL string rather than inspecting an explicit query enum.

Build System & Tooling

The build harness uses a modular GNU Makefile supporting specialized profiles and automated diagnosis:

  • Sanitizers & Profiles: Configured with make debug (-O0 -g -DDEBUG), make release (-O2 -DNDEBUG), and make run-asan, which compiles with AddressSanitizer and writes logs directly to $(LOG_DIR)/asan.log.
  • Leak Detection: Automated make valgrind target running non-interactive profiling with --leak-check=full --track-origins=yes writing to valgrind.log.
  • Crash Diagnostics: Headless make gdb-report and make lldb-report targets run non-interactive batch debugging sessions that automatically dump stack backtraces to log files on abnormal termination.
  • Platform Portability: Header dependencies track automatically via -MMD -MP. Platform branching in the Makefile detects Darwin systems to resolve uuid directly from macOS system headers while falling back to pkg-config uuid on Linux.