Skip to content

Latest commit

 

History

8 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

rustdo

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


Features

  • 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.

Requirements

  • A Rust toolchain (2021 edition). Option::is_none_or is 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.

Dependencies

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"] }

Building and running

# clone, then
cargo build --release
./target/release/rustdo

Or, during development:

cargo run

The 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.


Usage

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.

The View pipeline

View tasks walks you through seven numbered filter steps, then sorting, then page size:

  1. Title — case-insensitive substring match. ESC to skip.
  2. Priority — multi-select, all pre-selected.
  3. Completeness — multi-select over Done / Unfinished, all pre-selected.
  4. 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.
  5. Creation date — inclusive lower and upper bounds.
  6. Modification date — inclusive lower and upper bounds.
  7. 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.

Example task output

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

Data format

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:

  • priority is 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.
  • due is either null or a timestamp at local midnight; only the date is ever entered by the user.
  • categories is a sorted, de-duplicated array.
  • The map key and the id field 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.


Project structure

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.


Design notes

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.


Known limitations

  • Fixed save path. The Option<&str> path parameter on load_tasks / save_tasks is plumbed through but always called with None; 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.

About

My first Rust project - a basic CLI-based task planner, the capstone project of Microsoft's Rust for Python Programmers course.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages