A terminal task manager written in Rust. rustdo is an interactive, menu-driven
to-do list: everything happens through arrow-key prompts, date pickers and
autocompleting text fields rather than command-line flags. Tasks are persisted as
pretty-printed JSON next to the binary. And implementation of the capstone project of Microsoft's Rust for Python Programmers course.
Version 1.1
- Interactive TUI prompts — all input goes through
inquire: single-select menus, multi-select filters, a calendar widget for dates, and text fields with validation. - Rich task model — title, priority (Low/Medium/High), completion flag, creation and modification timestamps, an optional due date, and a set of free-form category tags.
- Category autocomplete — while typing a comma-separated category list, the currently-edited segment is completed against categories already used by other tasks, so tag vocabulary stays consistent without a fixed taxonomy.
- Filtering — by title substring (case-insensitive), priority, completeness, due-date range, creation-date range, modification-date range, and categories (ANY/ALL matching). Date-range filters let you decide whether tasks without a due date are included.
- Multi-key sorting — pick several sort fields in order, each with its own direction; sorting is applied as a lexicographic cascade (first field wins, later fields break ties).
- Pagination — configurable page size with wraparound next/previous navigation.
- Colourised output — priorities, overdue warnings and completion status are
colour-coded via
colored. - Statistics view — totals, done/pending counts, pending tasks broken down by priority, plus due-soon and overdue counts.
- Gap-filling IDs — deleting a task frees its ID, and the next task created reuses the lowest free slot instead of growing the ID space forever.
- A Rust toolchain (2021 edition).
Option::is_none_oris used, so Rust 1.82 or newer is required. - A terminal that supports ANSI colour and raw-mode input (Windows Terminal, any modern Linux/macOS terminal). Output includes emoji, so a font with emoji coverage helps.
serde = "1"
serde_json = "1"
serde_derive = "1"
inquire = { version = "0.9", features = ["date"] }
chrono = { version = "0.4", features = ["serde"] }
colored = "3"
strum = { version = "0.28", features = ["derive"] }# clone, then
cargo build --release
./target/release/rustdoOr, during development:
cargo runThe task database is read from and written to ./tasks.json, resolved relative
to the current working directory, not the binary location. If the file does
not exist on startup a warning is printed and an empty database is used; the file
is created on first save.
On launch you get the main menu:
| Option | What it does |
|---|---|
| Add a new task | Prompts for title (required), priority, due date (ESC to skip), and categories (ESC to skip). Assigns the next free ID. |
| Mark task as done | Pick an existing ID; sets done = true and bumps the modification timestamp. |
| Edit a task | Pick an ID; the same prompts as Add, pre-filled with the current values. |
| Remove a task | Pick an ID and delete it. The freed ID becomes the next ID to be assigned. |
| View tasks | Runs the filter → sort → paginate pipeline described below. |
| Task statistics | Prints the summary block. |
| Save and exit | Self-explanatory. |
View tasks walks you through seven numbered filter steps, then sorting, then page size:
- Title — case-insensitive substring match. ESC to skip.
- Priority — multi-select, all pre-selected.
- Completeness — multi-select over Done / Unfinished, all pre-selected.
- Due date — inclusive lower bound (4a) and upper bound (4b), either skippable. If either is set, step 4c asks whether tasks without a due date should be included or excluded.
- Creation date — inclusive lower and upper bounds.
- Modification date — inclusive lower and upper bounds.
- Categories — comma-separated, autocompleted, case-insensitive. If set, step 7b asks whether a task must match ANY of the given tags or ALL of them.
Sorting then repeatedly asks for a field and a direction. Each chosen field is removed from the candidate list and the running sort order is echoed back, e.g.:
Sorting order is now: (Priority, Descending) -> (Due date, Ascending)
Press ESC to stop adding rules. Finally, choose tasks per page (default 4) and navigate with Previous / Next / Exit; paging wraps around at both ends. The pagination menu is skipped entirely when everything fits on one page.
---| TASK #3: Submit variation package [High, ❌Not done!]|---
Due: 2026-08-21 00:00:00 +02:00 - ⚠️OVERDUE!
Created: 2026-08-14 09:02:11.482 +02:00 (Modified: 2026-08-18 16:40:55.019 +02:00)
Categories: regulatory, urgent
tasks.json is a JSON object mapping stringified task IDs to task objects:
{
"0": {
"id": 0,
"title": "Draft SOP revision",
"priority": "Medium",
"done": false,
"created": "2026-08-14T09:02:11.482391+02:00",
"modified": "2026-08-14T09:02:11.482391+02:00",
"due": "2026-08-25T00:00:00+02:00",
"categories": ["documentation", "internal"]
},
"1": {
"id": 1,
"title": "Review deviation report",
"priority": "High",
"done": true,
"created": "2026-08-15T11:20:03.117044+02:00",
"modified": "2026-08-18T08:44:57.901233+02:00",
"due": null,
"categories": []
}
}Notes on the format:
priorityis the enum variant name —"Low","Medium"or"High".- Timestamps are RFC 3339 with the local UTC offset baked in. Moving a database between timezones preserves the instant, not the wall-clock time.
dueis eithernullor a timestamp at local midnight; only the date is ever entered by the user.categoriesis a sorted, de-duplicated array.- The map key and the
idfield are deliberately redundant. See Design notes.
The file is human-editable, but hand-editing invalid JSON or an unknown priority
string will cause a hard failure on startup (load_tasks propagates the
serde_json error and main calls .expect). Only a file not found error is
handled gracefully.
| File | Responsibility |
|---|---|
main.rs |
Entry point, ID bookkeeping, main menu loop, dispatch and top-level error reporting. |
tasks.rs |
The Task struct, its constructor/mutators and Display impl; the Priority, TaskCompleteness and TaskSortField enums with their parsing and comparison logic. |
logic.rs |
The interactive operations: add_or_update, mark_done, remove, stats, view_tasks. |
file_io.rs |
JSON load/save with buffered readers and writers; DEFAULT_FILE_PATH. |
utils.rs |
Free-standing helpers: find_id_gap, the UsizeValidator input validator, the CategoryAutocompleter, and the small UI enums (MenuOption, filter/sort/pagination options). |
Data flow is deliberately simple: main owns a single
BTreeMap<usize, Task> for the whole run and lends it out by reference to the
logic functions. The only other state-holding variable identifies which ID the next new task should be assigned, and I/O happens on startup and on every task modification.
Why a BTreeMap? Iteration in ascending key order comes for free, which the
ID allocator, the ID-selection prompts and the default "sorted by ID" view all
depend on. It also makes "what is the highest ID in use?" a keys().next_back()
away.
ID allocation. On startup, if tasks.len() - 1 doesn't equal the largest
key, there must be a hole in the ID sequence, and find_id_gap scans upward for
the first unused key. After each insert the allocator checks whether the next
ID is already occupied; if so it was filling a hole and searches for the next
one, otherwise it just increments. Removing a task returns the freed ID, and
main takes the minimum of that and the current candidate, so IDs stay dense.
Duplicated IDs. Each task stores its own id in addition to being keyed by
it in the map. This is intentional: a &Task can identify itself when printed or
sorted without the caller having to carry the key alongside it. The cost is that
the two can drift, so main guards the invariant with a debug_assert_eq! after
each insert.
Boxed filter chains. Iterator adaptors each produce a distinct type, so a
conditionally-built chain can't be expressed with concrete types. view_tasks
therefore holds a Box<dyn Iterator<Item = &Task>> and re-boxes it for each
filter that turns out to be active. This trades a little dynamic dispatch for the
ability to compose an arbitrary subset of filters, and stays lazy — nothing is
evaluated until collect().
Cascading sort. Multi-key sorting folds over the chosen (field, direction)
pairs, starting from Ordering::Equal and combining with then, which
short-circuits on the first non-equal comparison. Reversing the per-field
Ordering (rather than swapping the operands) gives descending order.
Cancellation. inquire reports ESC either as Err(OperationCanceled) or,
via prompt_skippable, as Ok(None). Both are treated as intent rather than
failure: an optional field left unset, or an abandoned operation that returns to
the menu without an error message.
- Fixed save path. The
Option<&str>path parameter onload_tasks/save_tasksis plumbed through but always called withNone; there is no way to point at a different database. - Prompt errors are fatal. Several prompts use
.expect("Prompt error"), so a terminal I/O failure panics instead of unwinding to the menu.