todo.filter
The todos a list view shows: all, active or completed, optionally one tag, and a search box, in input order.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 17 tests, run in TypeScript, Python and Rust.
What it does
The todos a list shows for its three controls: the view tab (`all`, `active`, `completed`), the tag picked in the sidebar, and the search box. A todo must pass all three, and the result keeps the input order, so sort first (todo.sort) or after, whichever the app prefers.
- **View**: `active` is not done, `completed` is done, `all` is both. Any other view is an error: a typo should not quietly show an empty list. - **Tag**: `null` means any tag. Otherwise the tag is normalised exactly as stored tags are (todo.normalise-tags), so `"#Home"` and `" HOME "` find todos tagged `home`, and `"Home Office"` finds `home-office`. It is an exact tag match: `home` does not find `home-office`. A tag that normalises to nothing (`"#"`, `" "`) matches no todo, since a tag filter was asked for and no todo carries an empty tag. The todos' own tags are compared as stored; todo.item keeps them normalised. - **Search**: the query is trimmed of ASCII spaces, then matched as a case-insensitive (ASCII only) substring of the title, the notes, or the tags joined with spaces, using collections.search-text. So searching `bills` finds a todo tagged `bills`, and `home` finds one tagged `home-office`. An empty or all-space query matches everything.
For example
filter_todos(todos ×5, view all, tag —, query )→ ×5 all, no tag, empty search: everything in input orderfilter_todos(todos ×5, view active, tag —, query )→ ×3 active hides done todosfilter_todos(todos ×5, view completed, tag —, query )→ ×2 completed shows only done todos
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 filter_todos(todos: &[Todo], filter: &TodoFilter) -> Vec<Todo>
| todos | Todo[] | in the order the list shows them |
| filter | TodoFilter | what the view tabs, tag sidebar and search box hold |
| returns | Todo[] | the todos that pass all three, in input order |
The types it declares, generated into your project
// TodoView is a string in Rust, one of: "all", "active", "completed".
// Parameters take it as &str and results hold it as String.
/// What a todo list is narrowed to. All three parts must match.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TodoFilter {
pub view: String,
/// as typed, e.g. "#Home"; null for any tag
pub tag: Option<String>,
/// search box text; empty matches everything
pub query: String,
}
Your code names it in one line, in the file that uses it
fune!(todo.filter@^1); // then call filter_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::search_text; ← from collections.search-text ^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_draft::trim_space;
use super::todo_normalise_tags::normalise_tags; ← from todo.normalise-tags ^1.0.0 · built alongside by fune
/// The todos a list shows for its view, tag and search box, in input order.
///
/// The tag is normalised like stored tags, so "#Home" finds "home"; one that
/// normalises to nothing matches no todo. The search covers the title, the
/// notes and the tags joined with spaces (collections.search-text).
///
/// # Panics
/// Panics on an unknown view.
pub fn filter_todos(todos: &[Todo], filter: &TodoFilter) -> Vec<Todo> {
let view = filter.view.as_str();
if view != "all" && view != "active" && view != "completed" {
panic!("unknown view \"{}\": expected all, active or completed", view);
}
let wanted: Option<String> = match &filter.tag {
None => None,
Some(tag) => match normalise_tags(&[tag.clone()]).into_iter().next() {
None => return Vec::new(),
Some(t) => Some(t),
},
};
let kept: Vec<&Todo> = todos
.iter()
.filter(|t| !(view == "active" && t.done) && !(view == "completed" && !t.done))
.filter(|t| wanted.as_ref().map_or(true, |w| t.tags.contains(w)))
.collect();
let query = trim_space(&filter.query);
if query.is_empty() {
return kept.into_iter().cloned().collect();
}
let records: Vec<Value> = kept
.iter()
.enumerate()
.map(|(i, t)| {
Value::obj(vec![
("i", Value::Int(i as i64)),
("title", Value::str(&t.title)),
("notes", t.notes.as_deref().map(Value::str).unwrap_or(Value::Null)),
("tags", Value::Str(t.tags.join(" "))),
])
})
.collect();
let fields = vec!["title".to_string(), "notes".to_string(), "tags".to_string()];
search_text(&records, &fields, query)
.iter()
.map(|r| kept[r.get("i").as_i64() as usize].clone())
.collect()
}
pub fn todo_filter_from_value(v: &Value) -> TodoFilter {
TodoFilter {
view: v.get("view").as_str().to_string(),
tag: if v.get("tag").is_null() { None } else { Some(v.get("tag").as_str().to_string()) },
query: v.get("query").as_str().to_string(),
}
}
pub fn fune_vector(args: &[Value]) -> Value {
let todos: Vec<Todo> = args[0].as_arr().iter().map(todo_from_value).collect();
todos_to_value(&filter_todos(&todos, &todo_filter_from_value(&args[1])))
}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.filter
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./todo.filter-1.0.0-rust.fune, or fetch it from a terminal with fune pull todo.filter@1.0.0:rust.
The whole function, every language, is one file too: todo.filter-1.0.0.fune, 39,877 bytes, sha256 80695baaa6bba264a8d5e8cd28af7869ab720455532ec7ce43c7d3725d6622e7. 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.filter
after — your function gets the result and the arguments, and returns the final result.
// fune: after todo.filter
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.filter
// fune: replace todo.item in todo.filter
// fune: replace todo.normalise-tags in todo.filter
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.filter --steps.
// fune: step todo.filter 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 | |
|---|---|---|---|
| all, no tag, empty search: everything in input order | todos ×5, view all, tag —, query | → | ×5 |
| active hides done todos | todos ×5, view active, tag —, query | → | ×3 |
| completed shows only done todos | todos ×5, view completed, tag —, query | → | ×2 |
| tag "#Home" finds todos tagged home, but not home-office | todos ×5, view all, tag #Home, query | → | ×2 |
| tag and view combine: open todos tagged home | todos ×5, view active, tag Home, query | → | ×1 |
| a tag typed with spaces is normalised to the stored slug | todos ×5, view all, tag Home Office , query | → | ×1 |
| a tag that normalises to nothing matches no todo | todos ×5, view all, tag #, query | → | |
| the search is trimmed: " milk " finds the title and the notes | todos ×5, view all, tag —, query milk | → | ×2 |
| the search ignores ASCII case | todos ×5, view all, tag —, query MILK | → | ×2 |
| the search looks in tags too | todos ×5, view all, tag —, query bills | → | ×1 |
Show the other 7 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a tag search is a substring: home also finds home-office | todos ×5, view active, tag —, query home | → | ×2 |
| the search looks in notes | todos ×5, view all, tag —, query order | → | ×1 |
| a search of only spaces matches everything | todos ×5, view active, tag —, query | → | ×3 |
| view, tag and search all have to match | todos ×5, view active, tag home, query milk | → | ×1 |
| nothing matches the search | todos ×5, view all, tag —, query xyz | → | |
| an empty list stays empty | , view active, tag home, query milk | → | |
| an unknown view is an error | todos ×5, view archived, tag —, query | → | error: unknown view "archived": expected all, active or completed |
Files
| Path | Bytes |
|---|---|
| README.md | 1,275 |
| impl/python.py | 1,646 |
| impl/rust.rs | 2,576 |
| impl/typescript.ts | 1,781 |
| vectors.json | 26,078 |