todo.sort
Sort todos by manual order, due date, priority, creation time or title, stably, with sensible tie-breakers.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 20 tests, run in TypeScript, Python and Rust.
What it does
Sorts todos for a list view by one key, with fixed tie-breakers so the order never jumps around between renders. Built from stable collections.sort-by passes, from the least to the most significant key, then mapped back to the original todos.
| key | primary | ties, always ascending | | --- | --- | --- | | `manual` | `order` | creation time, then input order | | `due` | due date | priority high to none, then `order` | | `priority` | none 0, low 1, medium 2, high 3 (`desc` is high first) | due date, then `order` | | `created` | `createdAt` | `order` | | `title` | title with A-Z folded to a-z, then code-point order | `order` |
For example
sort_todos(todos ×4, manual, asc)→ ×4 manual ascending follows ordersort_todos(todos ×4, manual, desc)→ ×4 manual descending reverses ordersort_todos(todos ×4, due, asc)→ ×4 due ascending: earliest first, same day high priority first, no date last
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
pub fn sort_todos(todos: &[Todo], by: &str, direction: &str) -> Vec<Todo>
| todos | Todo[] | |
| by | TodoSortKey | the primary key; each has fixed ascending tie-breakers |
| direction | SortDirection | applies to the primary key only; todos with no due date stay last either way |
| returns | Todo[] | a new list; the input is not reordered |
The type it declares, generated into your project
// TodoSortKey is a string in Rust, one of: "manual", "due", "priority", "created", "title".
// Parameters take it as &str and results hold it as String.
Your code names it in one line, in the file that uses it
fune!(todo.sort@^1); // then call sort_todos(…)
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::collections_search_text::fold_ascii; ← from collections.search-text ^1.0.0 · built alongside by fune
use super::collections_sort_by::sort_by; ← from collections.sort-by ^1.0.0 · built alongside by fune
use super::todo_item::{todo_from_value, todos_to_value, Todo}; ← from todo.item ^1.0.0 · built alongside by fune
use super::todo_item_validate_todo::is_utc_timestamp;
/// Sort passes, least significant first; the last is the primary key and takes the direction.
fn passes(by: &str) -> Option<&'static [(&'static str, &'static str)]> {
match by {
"manual" => Some(&[("created", "asc"), ("order", "dir")]),
"due" => Some(&[("order", "asc"), ("rank", "desc"), ("due", "dir")]),
"priority" => Some(&[("order", "asc"), ("due", "asc"), ("rank", "dir")]),
"created" => Some(&[("order", "asc"), ("created", "dir")]),
"title" => Some(&[("order", "asc"), ("title", "dir")]),
_ => None,
}
}
fn rank(priority: &str) -> i64 {
match priority {
"none" => 0,
"low" => 1,
"medium" => 2,
"high" => 3,
other => panic!("unknown priority \"{}\": expected none, low, medium or high", other),
}
}
/// A creation time that sorts as text: the fraction padded to nine digits, so
/// "...:00Z" comes before "...:00.5Z", where the raw strings put "." first.
fn created_key(created_at: &str) -> String {
if !is_utc_timestamp(created_at) {
panic!("createdAt \"{}\" is not a UTC timestamp, e.g. 2026-09-28T09:30:00Z", created_at);
}
let fraction = if created_at.len() > 20 { &created_at[20..created_at.len() - 1] } else { "" };
format!("{}.{:0<9}", &created_at[..19], fraction)
}
/// Todos in list order for the chosen key: stable, built from
/// collections.sort-by passes from the least to the most significant key; the
/// direction flips only the primary key, and no-date todos stay last.
///
/// # Panics
/// Panics on an unknown key or direction, an unknown priority (due and
/// priority sorts) or a createdAt that is not a UTC timestamp (manual and
/// created sorts).
pub fn sort_todos(todos: &[Todo], by: &str, direction: &str) -> Vec<Todo> {
let passes = match passes(by) {
Some(p) => p,
None => panic!("unknown sort key \"{}\": expected manual, due, priority, created or title", by),
};
if direction != "asc" && direction != "desc" {
panic!("direction must be \"asc\" or \"desc\", received \"{}\"", direction);
}
let uses = |field: &str| passes.iter().any(|(f, _)| *f == field);
let mut records: Vec<Value> = todos
.iter()
.enumerate()
.map(|(i, todo)| {
let mut fields: Vec<(&str, Value)> = vec![("i", Value::Int(i as i64)), ("order", Value::Int(todo.order))];
if uses("created") {
fields.push(("created", Value::Str(created_key(&todo.created_at))));
}
if uses("rank") {
fields.push(("rank", Value::Int(rank(&todo.priority))));
}
if uses("due") {
fields.push(("due", todo.due.as_deref().map(Value::str).unwrap_or(Value::Null)));
}
if uses("title") {
fields.push(("title", Value::Str(fold_ascii(&todo.title))));
}
Value::obj(fields)
})
.collect();
for (field, d) in passes {
records = sort_by(&records, field, if *d == "dir" { direction } else { d });
}
records.iter().map(|r| todos[r.get("i").as_i64() as usize].clone()).collect()
}
pub fn fune_vector(args: &[Value]) -> Value {
let todos: Vec<Todo> = args[0].as_arr().iter().map(todo_from_value).collect();
todos_to_value(&sort_todos(&todos, args[1].as_str(), args[2].as_str()))
}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.sort
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./todo.sort-1.0.0-rust.fune, or fetch it from a terminal with fune pull todo.sort@1.0.0:rust.
The whole function, every language, is one file too: todo.sort-1.0.0.fune, 43,900 bytes, sha256 c1d30450c183f2c1d6ceb267684929429e8bf879c49ad01d2e3803d07240012e. 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.sort
after — your function gets the result and the arguments, and returns the final result.
// fune: after todo.sort
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 collections.search-text in todo.sort
// fune: replace collections.sort-by in todo.sort
// fune: replace todo.item in todo.sort
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show todo.sort --steps.
// fune: step todo.sort 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.
| Case | Arguments | Expected | |
|---|---|---|---|
| manual ascending follows order | todos ×4, manual, asc | → | ×4 |
| manual descending reverses order | todos ×4, manual, desc | → | ×4 |
| due ascending: earliest first, same day high priority first, no date last | todos ×4, due, asc | → | ×4 |
| due descending: latest first, but no date still last; ties still high priority first | todos ×4, due, desc | → | ×4 |
| priority ascending: none, low, then the two highs by due date, no date last | todos ×4, priority, asc | → | ×4 |
| priority descending: high first, ties by due date ascending with no date last | todos ×4, priority, desc | → | ×4 |
| created ascending: 09:00:00Z before 09:00:00.5Z (plain text order gets this wrong) | todos ×4, created, asc | → | ×4 |
| created descending | todos ×4, created, desc | → | ×4 |
| title ascending ignores case: apple, apple pie, Banana, cherry | todos ×4, title, asc | → | ×4 |
| title descending ignores case | todos ×4, title, desc | → | ×4 |
Show the other 10 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| titles equal but for case tie on order | todos ×3, title, asc | → | ×3 |
| tie-breakers stay ascending when the title sort is descending | todos ×3, title, desc | → | ×3 |
| manual ties go by creation time, then input order | todos ×3, manual, asc | → | ×3 |
| manual ties stay oldest first when descending | todos ×3, manual, desc | → | ×3 |
| no due date is last descending too, and those keep order | todos ×3, due, desc | → | ×3 |
| an empty list | , due, asc | → | |
| an unknown sort key is an error | todos ×4, alphabetical, asc | → | error: unknown sort key "alphabetical": expected manual, due, priority, created or title |
| an unknown direction is an error | todos ×4, title, up | → | error: direction must be "asc" or "desc", received "up" |
| sorting by creation time needs real timestamps | todos ×1, created, asc | → | error: createdAt "yesterday" is not a UTC timestamp |
| sorting by priority needs a known priority | todos ×1, priority, asc | → | error: unknown priority "urgent": expected none, low, medium or high |
More from the author
- `direction` flips the primary key only. Todos with no due date are last in both directions, in the `due` sort and as a tie-breaker, because a descending due list should start with the latest real date, not the undated. - Anything still tied keeps its input order. - Creation times are compared as instants, not raw text: `…09:00:00Z` sorts before `…09:00:00.5Z`, which plain string comparison gets backwards (`.` comes before `Z`). The `manual` and `created` sorts therefore need every `createdAt` to be a UTC timestamp and fail loudly otherwise; the `due` and `priority` sorts need a known priority. The other sorts do not look at those fields. - Title case folding is ASCII only (collections.search-text's `foldAscii`), so "apple" and "Banana" sort as a person expects, identically in every language; accented letters sort after z by code point. - An unknown key or direction is an error.
Files
| Path | Bytes |
|---|---|
| README.md | 1,563 |
| impl/python.py | 2,568 |
| impl/rust.rs | 3,614 |
| impl/typescript.ts | 3,116 |
| vectors.json | 26,544 |