Functional Weave
Code in Rust

todo.list

The to-do list reducer: add, edit, complete, remove, clear and reorder todos, each returning a new list.

1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra

Pinned by 94 tests, run in TypeScript, Python and Rust.addTodo 19 · updateTodo 12 · toggleTodo 18 · removeTodo 11 · clearCompleted 8 · toggleAll 12 · moveTodo 14

What it does

The reducer over a to-do list: every change a to-do app makes to its list, as functions that take the list and return a new one. The input is never changed, and the array keeps its order unless a function says otherwise, so the result can go straight into a state store, an undo stack or storage.

| Function | What it does | | --- | --- | | `addTodo(todos, draft, id, now)` | Appends a new open todo built from the add form. | | `updateTodo(todos, id, draft)` | Replaces a todo's editable fields from the edit form. | | `toggleTodo(todos, id, now, today)` | Completes or reopens one todo. | | `removeTodo(todos, id)` | Deletes one todo. | | `clearCompleted(todos)` | Deletes every done todo. | | `toggleAll(todos, now, today)` | Completes everything open, or reopens everything once all are done. | | `moveTodo(todos, id, toIndex)` | Drag and drop in manual order. |

The functions

A group: 7 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.

  1. add_todo (todos: Todo[], draft: TodoDraft, id: string, now: string) -> Todo[]
  2. update_todo (todos: Todo[], id: string, draft: TodoDraft) -> Todo[]
  3. toggle_todo (todos: Todo[], id: string, now: string, today: date) -> Todo[]
  4. remove_todo (todos: Todo[], id: string) -> Todo[]
  5. clear_completed (todos: Todo[]) -> Todo[]
  6. toggle_all (todos: Todo[], now: string, today: date) -> Todo[]
  7. move_todo (todos: Todo[], id: string, toIndex: int) -> Todo[]

Once installed, your code imports each one from the group's module.

add_todo throws on bad input 19 tests

pub fn add_todo(todos: &[Todo], draft: &TodoDraft, id: &str, now: &str) -> Vec<Todo>
todosTodo[]
draftTodoDraftwhat the add form submitted; trimmed and tags normalised before it is checked
idstringa new id the app generated
nowstringthe current UTC timestamp, e.g. 2026-09-28T09:30:00Z
returnsTodo[]the list with the new todo appended, last in manual order

For example

  • add_todo(, title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z) → ×1 the first todo in an empty list has order 0
  • add_todo(todos ×2, title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z) → ×3 appended after the others, one past the highest order
  • add_todo(todos ×2, title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z) → ×3 order is one past the highest, not the count, and it still goes at the end of the array
fune!(todo.list@^1);  // then call add_todo(…)
impl/rust/add_todo.rs · 70 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::todo_item::{validate_draft, Todo, TodoDraft};  ← from todo.item ^1.0.0 · built alongside by fune
use super::todo_item_validate_draft::{todo_draft_from_value, trim_space};
use super::todo_item_validate_todo::{is_utc_timestamp, todos_from_value, todos_to_value};
use super::todo_normalise_tags::normalise_tags;  ← from todo.normalise-tags ^1.0.0 · built alongside by fune

/// Refuse a clock reading that would not compare as a string with the stored ones.
pub fn require_utc_now(now: &str) {
    if !is_utc_timestamp(now) {
        panic!("now must be a UTC timestamp like 2026-09-28T09:30:00Z");
    }
}

/// The draft as it will be stored: title and notes trimmed (ASCII only),
/// blank notes as None, tags in normal form. Then checked with validate_draft;
/// the first field in field order that fails is the error.
pub fn checked_draft(draft: &TodoDraft) -> TodoDraft {
    let notes = draft.notes.as_deref().map(trim_space).unwrap_or("");
    let clean = TodoDraft {
        title: trim_space(&draft.title).to_string(),
        notes: if notes.is_empty() { None } else { Some(notes.to_string()) },
        priority: draft.priority.clone(),
        due: draft.due.clone(),
        tags: normalise_tags(&draft.tags),
        recurrence: draft.recurrence.clone(),
    };
    let verdict = validate_draft(&clean);
    if let Some((field, message)) = verdict.errors.first() {
        panic!("invalid todo: {}: {}", field, message);
    }
    clean
}

/// Add a todo from the add form: open, created now, last in manual order,
/// appended. Caller mistakes (id, clock) are reported before form mistakes.
///
/// # Panics
/// On a blank or duplicate id, a `now` that is not a UTC timestamp, or an
/// invalid draft.
pub fn add_todo(todos: &[Todo], draft: &TodoDraft, id: &str, now: &str) -> Vec<Todo> {
    if trim_space(id).is_empty() {
        panic!("a todo needs an id");
    }
    if todos.iter().any(|t| t.id == id) {
        panic!("a todo with id \"{}\" already exists", id);
    }
    require_utc_now(now);
    let clean = checked_draft(draft);
    let order = todos.iter().map(|t| t.order + 1).fold(0, i64::max);
    let mut out = todos.to_vec();
    out.push(Todo {
        id: id.to_string(),
        title: clean.title,
        notes: clean.notes,
        done: false,
        priority: clean.priority,
        due: clean.due,
        tags: clean.tags,
        recurrence: clean.recurrence,
        created_at: now.to_string(),
        completed_at: None,
        order,
    });
    out
}

pub fn fune_vector(args: &[Value]) -> Value {
    let todos = todos_from_value(&args[0]);
    todos_to_value(&add_todo(&todos, &todo_draft_from_value(&args[1]), args[2].as_str(), args[3].as_str()))
}

update_todo throws on bad input 12 tests

pub fn update_todo(todos: &[Todo], id: &str, draft: &TodoDraft) -> Vec<Todo>
todosTodo[]
idstring
draftTodoDraftthe edit form's fields; id, done, timestamps and order are kept
returnsTodo[]

For example

  • update_todo(todos ×3, b, title Pay the rent, notes by standing order, priority high, due 2026-10-01, tags home, recurrence —) → ×3 replaces the edited fields of one todo and leaves the others alone
  • update_todo(todos ×1, b, title Pay the rent, notes —, priority none, due —, tags , recurrence —) → ×1 a done todo stays done with its completion time
  • update_todo(todos ×1, a, title Walk the dog twice , notes —, priority none, due —, tags Dog Walks, #dog-walks, recurrence —) → ×1 trims the title and normalises tags as addTodo does
fune!(todo.list@^1);  // then call update_todo(…)
impl/rust/update_todo.rs · 37 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::todo_item::{Todo, TodoDraft};  ← from todo.item ^1.0.0 · built alongside by fune
use super::todo_item_validate_draft::todo_draft_from_value;
use super::todo_item_validate_todo::{todos_from_value, todos_to_value};
use super::todo_list_add_todo::checked_draft;  ← addTodo, another function of this group · built into the same file, even by a slim install

/// The position of the todo with this id, or the unknown-id panic every list
/// operation shares.
pub fn index_of_todo(todos: &[Todo], id: &str) -> usize {
    match todos.iter().position(|t| t.id == id) {
        Some(at) => at,
        None => panic!("no todo with id \"{}\"", id),
    }
}

/// Save the edit form; id, done state, timestamps, order and place are kept.
///
/// # Panics
/// On an unknown id or an invalid draft.
pub fn update_todo(todos: &[Todo], id: &str, draft: &TodoDraft) -> Vec<Todo> {
    let at = index_of_todo(todos, id);
    let clean = checked_draft(draft);
    let mut out = todos.to_vec();
    let t = &mut out[at];
    t.title = clean.title;
    t.notes = clean.notes;
    t.priority = clean.priority;
    t.due = clean.due;
    t.tags = clean.tags;
    t.recurrence = clean.recurrence;
    out
}

pub fn fune_vector(args: &[Value]) -> Value {
    let todos = todos_from_value(&args[0]);
    todos_to_value(&update_todo(&todos, args[1].as_str(), &todo_draft_from_value(&args[2])))
}

toggle_todo throws on bad input 18 tests

pub fn toggle_todo(todos: &[Todo], id: &str, now: &str, today: &str) -> Vec<Todo>
todosTodo[]
idstring
nowstringwhen it was completed
todaydatethe user's local date, for rolling a repeating todo forward
returnsTodo[]done or reopened; a repeating todo stays open with its next due date

For example

  • toggle_todo(todos ×1, a, 2026-09-28T09:30:00Z, 2026-09-28) → ×1 completing an open todo sets done and the completion time
  • toggle_todo(todos ×1, b, 2026-09-28T09:30:00Z, 2026-09-28) → ×1 toggling a done todo reopens it and clears the completion time
  • toggle_todo(todos ×3, b, 2026-09-28T09:30:00Z, 2026-09-28) → ×3 only the named todo changes
fune!(todo.list@^1);  // then call toggle_todo(…)
impl/rust/toggle_todo.rs · 57 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::todo_item::Todo;  ← from todo.item ^1.0.0 · built alongside by fune
use super::todo_item_validate_draft::is_iso_date;
use super::todo_item_validate_todo::{todos_from_value, todos_to_value};
use super::todo_list_add_todo::require_utc_now;  ← addTodo, another function of this group · built into the same file, even by a slim install
use super::todo_list_update_todo::index_of_todo;  ← updateTodo, another function of this group · built into the same file, even by a slim install
use super::todo_next_occurrence::next_occurrence;  ← from todo.next-occurrence ^1.0.0 · built alongside by fune

/// Refuse a today that is not a real YYYY-MM-DD date, whether or not a repeat needs it.
pub fn require_today(today: &str) {
    if !is_iso_date(today) {
        panic!("today must be a date like 2026-09-28");
    }
}

/// Complete one open todo. A one-off is done at `now`; a repeating one stays
/// open and rolls forward to its next occurrence (todo.next-occurrence).
pub fn complete_todo(todo: &Todo, now: &str, today: &str) -> Todo {
    let mut next = todo.clone();
    match (&todo.recurrence, todo.due.as_deref()) {
        (None, _) => {
            next.done = true;
            next.completed_at = Some(now.to_string());
        }
        (Some(_), None) => panic!("repeating todo \"{}\" has no due date", todo.id),
        (Some(rule), Some(due)) => {
            next.done = false;
            next.completed_at = None;
            next.due = Some(next_occurrence(rule, due, today));
        }
    }
    next
}

/// Tick or untick one todo. Reopening clears the completion time.
///
/// # Panics
/// On an unknown id, a `now` that is not a UTC timestamp or a `today` that is
/// not a date.
pub fn toggle_todo(todos: &[Todo], id: &str, now: &str, today: &str) -> Vec<Todo> {
    let at = index_of_todo(todos, id);
    require_utc_now(now);
    require_today(today);
    let mut out = todos.to_vec();
    if out[at].done {
        out[at].done = false;
        out[at].completed_at = None;
    } else {
        out[at] = complete_todo(&todos[at], now, today);
    }
    out
}

pub fn fune_vector(args: &[Value]) -> Value {
    let todos = todos_from_value(&args[0]);
    todos_to_value(&toggle_todo(&todos, args[1].as_str(), args[2].as_str(), args[3].as_str()))
}

remove_todo throws on bad input 11 tests

pub fn remove_todo(todos: &[Todo], id: &str) -> Vec<Todo>
todosTodo[]
idstring
returnsTodo[]

For example

  • remove_todo(todos ×3, b) → ×2 removes the middle todo; the others keep their order values
  • remove_todo(todos ×3, a) → ×2 removes the first
  • remove_todo(todos ×3, c) → ×2 removes the last
fune!(todo.list@^1);  // then call remove_todo(…)
impl/rust/remove_todo.rs · 20 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::todo_item::Todo;  ← from todo.item ^1.0.0 · built alongside by fune
use super::todo_item_validate_todo::{todos_from_value, todos_to_value};
use super::todo_list_update_todo::index_of_todo;  ← updateTodo, another function of this group · built into the same file, even by a slim install

/// Delete one todo; the others keep their order values (gaps are harmless).
///
/// # Panics
/// On an unknown id.
pub fn remove_todo(todos: &[Todo], id: &str) -> Vec<Todo> {
    let at = index_of_todo(todos, id);
    let mut out = todos.to_vec();
    out.remove(at);
    out
}

pub fn fune_vector(args: &[Value]) -> Value {
    let todos = todos_from_value(&args[0]);
    todos_to_value(&remove_todo(&todos, args[1].as_str()))
}

clear_completed 8 tests

pub fn clear_completed(todos: &[Todo]) -> Vec<Todo>
todosTodo[]
returnsTodo[]the open todos only

For example

  • clear_completed() → an empty list stays empty
  • clear_completed(todos ×3) → ×3 nothing done: the list is unchanged
  • clear_completed(todos ×3) → everything done: nothing left
fune!(todo.list@^1);  // then call clear_completed(…)
impl/rust/clear_completed.rs · 12 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::todo_item::Todo;  ← from todo.item ^1.0.0 · built alongside by fune
use super::todo_item_validate_todo::{todos_from_value, todos_to_value};

/// Every done todo removed, the rest untouched and in order.
pub fn clear_completed(todos: &[Todo]) -> Vec<Todo> {
    todos.iter().filter(|t| !t.done).cloned().collect()
}

pub fn fune_vector(args: &[Value]) -> Value {
    todos_to_value(&clear_completed(&todos_from_value(&args[0])))
}

toggle_all throws on bad input 12 tests

pub fn toggle_all(todos: &[Todo], now: &str, today: &str) -> Vec<Todo>
todosTodo[]
nowstring
todaydate
returnsTodo[]completes every open todo, or reopens all when none is open

For example

  • toggle_all(, 2026-09-28T09:30:00Z, 2026-09-28) → an empty list stays empty
  • toggle_all(todos ×2, 2026-09-28T09:30:00Z, 2026-09-28) → ×2 all open: all completed now
  • toggle_all(todos ×3, 2026-09-28T09:30:00Z, 2026-09-28) → ×3 some open: the open ones are completed, the done one keeps its time
fune!(todo.list@^1);  // then call toggle_all(…)
impl/rust/toggle_all.rs · 31 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::todo_item::Todo;  ← from todo.item ^1.0.0 · built alongside by fune
use super::todo_item_validate_todo::{todos_from_value, todos_to_value};
use super::todo_list_add_todo::require_utc_now;  ← addTodo, another function of this group · built into the same file, even by a slim install
use super::todo_list_toggle_todo::{complete_todo, require_today};  ← toggleTodo, another function of this group · built into the same file, even by a slim install

/// Complete every open todo as toggle_todo would (repeating ones roll forward
/// and stay open), or reopen all once none is open.
///
/// # Panics
/// On a `now` that is not a UTC timestamp or a `today` that is not a date.
pub fn toggle_all(todos: &[Todo], now: &str, today: &str) -> Vec<Todo> {
    require_utc_now(now);
    require_today(today);
    if todos.iter().any(|t| !t.done) {
        return todos.iter().map(|t| if t.done { t.clone() } else { complete_todo(t, now, today) }).collect();
    }
    todos
        .iter()
        .map(|t| {
            let mut open = t.clone();
            open.done = false;
            open.completed_at = None;
            open
        })
        .collect()
}

pub fn fune_vector(args: &[Value]) -> Value {
    todos_to_value(&toggle_all(&todos_from_value(&args[0]), args[1].as_str(), args[2].as_str()))
}

move_todo throws on bad input 14 tests

pub fn move_todo(todos: &[Todo], id: &str, to_index: i64) -> Vec<Todo>
todosTodo[]
idstring
to_indexintwhere it ends up in manual order, 0 first
returnsTodo[]in manual order, with order renumbered 0 to n-1

For example

  • move_todo(todos ×3, a, 2) → ×3 move the first to the end
  • move_todo(todos ×3, c, 0) → ×3 move the last to the top
  • move_todo(todos ×3, b, 1) → ×3 moving to where it already is renumbers the orders
fune!(todo.list@^1);  // then call move_todo(…)
impl/rust/move_todo.rs · 39 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::todo_item::Todo;  ← from todo.item ^1.0.0 · built alongside by fune
use super::todo_item_validate_todo::{todos_from_value, todos_to_value};
use super::todo_list_update_todo::index_of_todo;  ← updateTodo, another function of this group · built into the same file, even by a slim install

/// Move a todo to `to_index` in manual order (order ascending, ties by array
/// position, which need not already be sorted) and renumber every order 0 to
/// n-1. The result is in that manual order.
///
/// # Panics
/// On an unknown id or a `to_index` outside 0..n-1.
pub fn move_todo(todos: &[Todo], id: &str, to_index: i64) -> Vec<Todo> {
    let from = index_of_todo(todos, id);
    let last = todos.len() as i64 - 1;
    if to_index < 0 || to_index > last {
        panic!("toIndex must be between 0 and {}, received {}", last, to_index);
    }
    let mut positions: Vec<usize> = (0..todos.len()).collect();
    positions.sort_by_key(|&i| (todos[i].order, i));
    positions.retain(|&i| i != from);
    positions.insert(to_index as usize, from);
    positions
        .iter()
        .enumerate()
        .map(|(n, &i)| {
            let mut t = todos[i].clone();
            t.order = n as i64;
            t
        })
        .collect()
}

pub fn fune_vector(args: &[Value]) -> Value {
    let todos = todos_from_value(&args[0]);
    if let Value::Float(f) = &args[2] {
        panic!("toIndex must be between 0 and {}, received {}", todos.len() as i64 - 1, f);
    }
    todos_to_value(&move_todo(&todos, args[1].as_str(), args[2].as_i64()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add todo.list

That builds the whole group. To build only what you call, and whatever it uses inside the group:

fune add todo.list --only addTodo
Download for Rust todo.list-1.0.0-rust.fune · 97,181 bytes sha256 3b846456c8c4cfae47213172fdd61c240dd41e3d582856b7402f4618cab184c7

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./todo.list-1.0.0-rust.fune, or fetch it from a terminal with fune pull todo.list@1.0.0:rust.

The whole function, every language, is one file too: todo.list-1.0.0.fune, 112,220 bytes, sha256 0605cc9f5a249dd3b2fe88ecf7b795d4fba56c213a196f63fe789a337c56247d. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before todo.list.addTodo
// fune: before todo.list.updateTodo
// fune: before todo.list.toggleTodo
// fune: before todo.list.removeTodo
// fune: before todo.list.clearCompleted
// fune: before todo.list.toggleAll
// fune: before todo.list.moveTodo

after — your function gets the result and the arguments, and returns the final result.

// fune: after todo.list.addTodo
// fune: after todo.list.updateTodo
// fune: after todo.list.toggleTodo
// fune: after todo.list.removeTodo
// fune: after todo.list.clearCompleted
// fune: after todo.list.toggleAll
// fune: after todo.list.moveTodo

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace todo.item in todo.list
// fune: replace todo.next-occurrence in todo.list
// fune: replace todo.normalise-tags in todo.list

step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show todo.list --steps.

// fune: step todo.list.<fn> after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

addTodo 19 tests

CaseArgumentsExpected
the first todo in an empty list has order 0 , title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z → ×1
appended after the others, one past the highest order todos ×2, title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z → ×3
order is one past the highest, not the count, and it still goes at the end of the array todos ×2, title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z → ×3
title and notes are trimmed of ASCII whitespace , title Buy milk , notes call first , priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z → ×1
blank notes are stored as null , title Buy milk, notes , priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z → ×1
tags are normalised: # dropped, lower-case slugs, duplicates removed , title Buy milk, notes —, priority none, due —, tags #Home, home office , HOME, recurrence —, n, 2026-09-28T09:30:00Z → ×1
priority, due date and recurrence are kept as given , title Pay rent, notes —, priority high, due 2026-10-01, tags , recurrence …, n, 2026-09-28T09:30:00Z → ×1
a done todo already in the list is left alone todos ×1, title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z → ×2
eleven tags that normalise to ten distinct ones are fine , title Buy milk, notes —, priority none, due —, tags #a, A, b, c, d, e, f, g, h, i, j, recurrence —, n, 2026-09-28T09:30:00Z → ×1
eleven distinct tags are too many , title Buy milk, notes —, priority none, due —, tags a, b, c, d, e, f, g, h, i, j, k, recurrence —, n, 2026-09-28T09:30:00Z → error: invalid todo: tags: Use no more than 10 tags.
Show the other 9 tests
CaseArgumentsExpected
a blank title is refused , title , notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z → error: invalid todo: title: Enter a title.
with a blank title and an impossible due date, the title is reported first , title , notes —, priority none, due 2026-02-30, tags , recurrence —, n, 2026-09-28T09:30:00Z → error: invalid todo: title: Enter a title.
an impossible due date , title Buy milk, notes —, priority none, due 2026-02-30, tags , recurrence —, n, 2026-09-28T09:30:00Z → error: invalid todo: due: Enter the due date as a real date, YYYY-MM-DD.
an unknown priority , title Buy milk, notes —, priority urgent, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z → error: invalid todo: priority: Choose a priority: none, low, medium or high.
a repeat without a due date , title Buy milk, notes —, priority none, due —, tags , recurrence …, n, 2026-09-28T09:30:00Z → error: invalid todo: recurrence: A repeating todo needs a due date.
a blank id , title Buy milk, notes —, priority none, due —, tags , recurrence —, , 2026-09-28T09:30:00Z → error: a todo needs an id
an id already in the list todos ×2, title Buy milk, notes —, priority none, due —, tags , recurrence —, b, 2026-09-28T09:30:00Z → error: a todo with id "b" already exists
now with an offset is not a UTC timestamp , title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T10:30:00+01:00 → error: now must be a UTC timestamp like 2026-09-28T09:30:00Z
now as a bare date is not a timestamp , title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28 → error: now must be a UTC timestamp like 2026-09-28T09:30:00Z

updateTodo 12 tests

CaseArgumentsExpected
replaces the edited fields of one todo and leaves the others alone todos ×3, b, title Pay the rent, notes by standing order, priority high, due 2026-10-01, tags home, recurrence — → ×3
a done todo stays done with its completion time todos ×1, b, title Pay the rent, notes —, priority none, due —, tags , recurrence — → ×1
trims the title and normalises tags as addTodo does todos ×1, a, title Walk the dog twice , notes —, priority none, due —, tags Dog Walks, #dog-walks, recurrence — → ×1
an empty notes clears the notes to null todos ×1, a, title Walk the dog, notes , priority none, due —, tags , recurrence — → ×1
adds a repeat todos ×1, a, title Walk the dog, notes —, priority none, due 2026-09-28, tags , recurrence … → ×1
takes the due date and the repeat away todos ×1, a, title Walk the dog, notes —, priority none, due —, tags , recurrence — → ×1
keeps the todo's place in the array and its order, even when they differ todos ×2, y, title Why, notes —, priority none, due —, tags , recurrence — → ×2
keeps id and createdAt of a one-item list todos ×1, only, title New, notes —, priority low, due —, tags , recurrence — → ×1
an unknown id todos ×2, zz, title Buy milk, notes —, priority none, due —, tags , recurrence — → error: no todo with id "zz"
an unknown id in an empty list , a, title Buy milk, notes —, priority none, due —, tags , recurrence — → error: no todo with id "a"
Show the other 2 tests
CaseArgumentsExpected
a blank title todos ×1, a, title , notes —, priority none, due —, tags , recurrence — → error: invalid todo: title: Enter a title.
a repeat that starts after the due date todos ×1, a, title Walk, notes —, priority none, due 2026-09-28, tags , recurrence … → error: invalid todo: recurrence: The repeat cannot start after the due date.

toggleTodo 18 tests

CaseArgumentsExpected
completing an open todo sets done and the completion time todos ×1, a, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
toggling a done todo reopens it and clears the completion time todos ×1, b, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
only the named todo changes todos ×3, b, 2026-09-28T09:30:00Z, 2026-09-28 → ×3
a daily todo done on its day stays open and is due tomorrow todos ×1, r, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
monthly anchored on the 31st: January's rolls to 28 February todos ×1, r, 2026-01-31T10:00:00Z, 2026-01-31 → ×1
monthly anchored on the 31st: February's rolls to 31 March, not the 28th todos ×1, r, 2026-02-28T10:00:00Z, 2026-02-28 → ×1
an overdue daily todo skips the missed days: due tomorrow, not the 21st todos ×1, r, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
an overdue weekly todo done on its weekday is due next week todos ×1, r, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
a weekdays todo done on Friday is due Monday todos ×1, r, 2026-10-02T17:00:00Z, 2026-10-02 → ×1
a monthly todo done early moves past its due date, not back onto it todos ×1, r, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
Show the other 8 tests
CaseArgumentsExpected
a yearly todo anchored on 29 February, done late, is due 28 February next year todos ×1, r, 2026-03-01T08:00:00Z, 2026-03-01 → ×1
a done repeating todo is simply reopened, due date unchanged todos ×1, r, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
an unknown id todos ×1, zz, 2026-09-28T09:30:00Z, 2026-09-28 → error: no todo with id "zz"
now not in UTC todos ×1, a, 2026-09-28T09:30:00, 2026-09-28 → error: now must be a UTC timestamp like 2026-09-28T09:30:00Z
reopening still needs a valid now todos ×1, b, yesterday, 2026-09-28 → error: now must be a UTC timestamp like 2026-09-28T09:30:00Z
today that never existed todos ×1, a, 2026-09-28T09:30:00Z, 2026-02-30 → error: today must be a date like 2026-09-28
today with a trailing newline todos ×1, a, 2026-09-28T09:30:00Z, 2026-09-28 → error: today must be a date like 2026-09-28
a stored repeating todo without a due date cannot roll forward todos ×1, r, 2026-09-28T09:30:00Z, 2026-09-28 → error: repeating todo "r" has no due date

removeTodo 11 tests

CaseArgumentsExpected
removes the middle todo; the others keep their order values todos ×3, b → ×2
removes the first todos ×3, a → ×2
removes the last todos ×3, c → ×2
removing the only todo leaves an empty list todos ×1, a →
removes a done todo todos ×2, b → ×1
gaps in order are left as they are todos ×3, y → ×2
ids match exactly: removing a leaves A todos ×2, a → ×1
works when the array is not in order sequence todos ×3, x → ×2
an unknown id todos ×2, zz → error: no todo with id "zz"
an unknown id in an empty list , a → error: no todo with id "a"
Show the other 1 test
CaseArgumentsExpected
an id with a space around it is a different id todos ×1, a → error: no todo with id " a"

clearCompleted 8 tests

CaseArgumentsExpected
an empty list stays empty →
nothing done: the list is unchanged todos ×3 → ×3
everything done: nothing left todos ×3 →
keeps the open ones in their order todos ×3 → ×2
done at both ends todos ×3 → ×1
a single done todo todos ×1 →
order values are not renumbered todos ×3 → ×2
an open repeating todo stays todos ×2 → ×1

toggleAll 12 tests

CaseArgumentsExpected
an empty list stays empty , 2026-09-28T09:30:00Z, 2026-09-28 →
all open: all completed now todos ×2, 2026-09-28T09:30:00Z, 2026-09-28 → ×2
some open: the open ones are completed, the done one keeps its time todos ×3, 2026-09-28T09:30:00Z, 2026-09-28 → ×3
all done: all reopened todos ×2, 2026-09-28T09:30:00Z, 2026-09-28 → ×2
an open repeating todo rolls forward and stays open while the rest are completed todos ×2, 2026-09-28T09:30:00Z, 2026-09-28 → ×2
all done, including a repeating one: all reopened todos ×2, 2026-09-28T09:30:00Z, 2026-09-28 → ×2
only repeating todos: they roll forward and are still open afterwards todos ×1, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
a single open todo todos ×1, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
an overdue monthly todo anchored on the 31st rolls to 30 September todos ×1, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
now with an offset todos ×1, 2026-09-28T09:30:00+00:00, 2026-09-28 → error: now must be a UTC timestamp like 2026-09-28T09:30:00Z
Show the other 2 tests
CaseArgumentsExpected
a bad now is refused even for an empty list , now, 2026-09-28 → error: now must be a UTC timestamp like 2026-09-28T09:30:00Z
today in Arabic-Indic digits is not a date todos ×1, 2026-09-28T09:30:00Z, ٢٠٢٦-09-28 → error: today must be a date like 2026-09-28

moveTodo 14 tests

CaseArgumentsExpected
move the first to the end todos ×3, a, 2 → ×3
move the last to the top todos ×3, c, 0 → ×3
moving to where it already is renumbers the orders todos ×3, b, 1 → ×3
gaps in order: toIndex counts positions, not order values todos ×3, a, 1 → ×3
array not in order sequence: positions are in manual order, not array order todos ×3, a, 1 → ×3
equal orders are tied by array position todos ×3, c, 0 → ×3
a one-item list: order becomes 0 todos ×1, a, 0 → ×1
move the last of four into the middle todos ×4, d, 1 → ×4
a done todo moves like any other todos ×2, a, 1 → ×2
toIndex past the end todos ×3, a, 3 → error: toIndex must be between 0 and 2, received 3
Show the other 4 tests
CaseArgumentsExpected
a negative toIndex todos ×3, a, -1 → error: toIndex must be between 0 and 2, received -1
a fractional toIndex todos ×3, a, 1.5 → error: toIndex must be between 0 and 2, received 1.5
an unknown id todos ×1, zz, 0 → error: no todo with id "zz"
an unknown id in an empty list , a, 0 → error: no todo with id "a"

More from the author

## Adding and editing

`addTodo` and `updateTodo` clean the draft before they check it: the title and notes are trimmed of ASCII whitespace (todo.item's rule), notes that are blank become `null`, and tags go through todo.normalise-tags, so `#Home`, `home` and ` HOME ` are one tag. Priority, due date and recurrence are stored as given. The cleaned draft is then checked with todo.item's `validateDraft`, and the first failing field, in field order, is the error: `invalid todo: title: Enter a title.` Eleven tags that normalise to ten are fine; eleven distinct ones are not.

A new todo is open, `createdAt` is `now`, `completedAt` is null, and its `order` is one past the highest order in the list (0 in an empty list), so it is last in manual order even when the orders have gaps. It is appended to the end of the array.

`updateTodo` keeps the id, done state, both timestamps, order and array position.

## Completing

A one-off todo becomes done with `completedAt` = `now`; toggling a done todo reopens it and clears `completedAt`.

A repeating todo (recurrence not null) is not marked done: it stays open and its due date moves to its next occurrence, computed by todo.next-occurrence from the anchor, strictly after both the due date and `today`. A monthly todo anchored on 31 January goes 28 February, then 31 March; a daily one that is a week overdue is due tomorrow, not yesterday. This is Todoist's model, and it is chosen over "mark this one done and create the next copy" because:

- a pure function cannot mint the second id the copy would need; - reopening a completed copy would leave two live copies of the series; - `toggleAll` stays well defined: completing everything never grows the list.

A done repeating todo (from an import, say) is simply reopened.

`toggleAll` completes every open todo exactly as `toggleTodo` does while anything is open, and reopens everything once nothing is. So with open repeating todos in the list, pressing it again rolls them forward again rather than reopening the rest: they are still open.

## Moving

`moveTodo` puts the list in manual order (order ascending, ties broken by array position; the array need not already be sorted), takes the todo out, puts it back so that it ends up at `toIndex`, and renumbers every order 0 to n-1. The result is returned in that manual order. `removeTodo` and `clearCompleted` leave the remaining orders as they are: gaps are harmless, because manual order sorts by order rather than counting positions.

## Arguments, not globals

Ids, `now` and `today` are arguments. The app generates ids and reads the clock; the functions stay pure and testable. `now` must be a UTC timestamp like `2026-09-28T09:30:00Z` (todo.item's `isUtcTimestamp`), so that stored timestamps compare as strings; `today` must be a real `YYYY-MM-DD` date and is checked even when no repeating todo needs it, so a bad clock is caught on the first toggle rather than the first repeat.

## Errors

| Message | When | | --- | --- | | `a todo needs an id` | `addTodo` with a blank id | | `a todo with id "<id>" already exists` | `addTodo` with an id in the list | | `no todo with id "<id>"` | any other function given an id not in the list (ids match exactly) | | `now must be a UTC timestamp like 2026-09-28T09:30:00Z` | `addTodo`, `toggleTodo`, `toggleAll` | | `today must be a date like 2026-09-28` | `toggleTodo`, `toggleAll` | | `invalid todo: <field>: <message>` | `addTodo`, `updateTodo` with a draft that fails `validateDraft` | | `repeating todo "<id>" has no due date` | completing a stored repeating todo that has no due date (an invalid stored todo) | | `toIndex must be between 0 and <n-1>, received <x>` | `moveTodo` |

Arguments are checked in this order: the id, then the clock (`now`, `today`), then the draft, so a caller's mistake is reported before a form mistake. `toggleAll` checks `now` and `today` even for an empty list.

Files

PathBytes
README.md4,786
impl/python/add_todo.py2,140
impl/python/clear_completed.py235
impl/python/move_todo.py804
impl/python/remove_todo.py349
impl/python/toggle_all.py611
impl/python/toggle_todo.py1,387
impl/python/update_todo.py931
impl/rust/add_todo.rs2,628
impl/rust/clear_completed.rs418
impl/rust/move_todo.rs1,434
impl/rust/remove_todo.rs609
impl/rust/toggle_all.rs1,095
impl/rust/toggle_todo.rs2,002
impl/rust/update_todo.rs1,264
impl/typescript/add_todo.ts2,422
impl/typescript/clear_completed.ts255
impl/typescript/move_todo.ts977
impl/typescript/remove_todo.ts421
impl/typescript/toggle_all.ts717
impl/typescript/toggle_todo.ts1,666
impl/typescript/update_todo.ts1,079
vectors.json65,249