Functional Weave
Code in Rust

todo.normalise-tags

Clean up tags as typed: trim, drop the #, lower-case slug form, drop empties and duplicates, keep the order.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 13 tests, run in TypeScript, Python and Rust.

What it does

Turns tags as a person typed them into the one form a todo stores, so that `#Home`, `home` and ` HOME ` are the same tag and a filter on one finds all three.

Each tag loses its leading `#` characters and becomes a slug with text.slugify: lower-case ASCII letters and digits, runs of anything else collapsed to a single hyphen, never a hyphen at either end. So "Home Office" is `home-office`, "Café" is `cafe` and "Straße" is `strasse`. The transliteration covers Latin-1 letters only (see text.slugify): a tag written in another script, or made only of punctuation, comes out empty and is dropped rather than stored as an empty tag.

For example

  • normalise_tags(Home) → home a capital letter is lower-cased
  • normalise_tags(#home, HOME, home ) → home #home, HOME and a padded home are one tag
  • normalise_tags(Home Office) → home-office spaces inside become one hyphen

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 normalise_tags(tags: &[String]) -> Vec<String>
tagsstring[]as typed, e.g. ["#Home", " home office ", "HOME"]
returnsstring[]e.g. ["home", "home-office"], first appearance first

Your code names it in one line, in the file that uses it

fune!(todo.normalise-tags@^1);  // then call normalise_tags(…)
impl/rust.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::text_slugify::slugify;  ← from text.slugify ^1.0.0 · built alongside by fune

/// Tags as typed, in the one stored form: no #, lower-case slug, no empties,
/// no repeats, first appearance first.
pub fn normalise_tags(tags: &[String]) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    for tag in tags {
        let slug = slugify(tag.trim_start_matches(|c: char| c == '#' || c.is_ascii_whitespace() || c == '\u{0B}'));
        if !slug.is_empty() && !out.contains(&slug) {
            out.push(slug);
        }
    }
    out
}

pub fn fune_vector(args: &[Value]) -> Value {
    let tags: Vec<String> = args[0].as_arr().iter().map(|t| t.as_str().to_string()).collect();
    Value::Arr(normalise_tags(&tags).iter().map(|t| Value::str(t)).collect())
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, 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.normalise-tags
Download for Rust todo.normalise-tags-1.0.0-rust.fune · 4,711 bytes sha256 46b0dcf9bcfbfa5eeaaf5facd2c6f3c00ed8c1770ace449770d1596908bbfc33

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

The whole function, every language, is one file too: todo.normalise-tags-1.0.0.fune, 6,037 bytes, sha256 c098ae9cc4cff3719a9100ca369754dcc598f716578252f1b23114de7be44139. 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.normalise-tags

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

// fune: after todo.normalise-tags

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 text.slugify in todo.normalise-tags

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.normalise-tags --steps.

// fune: step todo.normalise-tags 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.

CaseArgumentsExpected
a capital letter is lower-cased Home → home
#home, HOME and a padded home are one tag #home, HOME, home → home
spaces inside become one hyphen Home Office → home-office
accented Latin letters lose their accent Café → cafe
sharp s becomes ss Straße → strasse
several leading #s go ##urgent → urgent
tags that come out empty are dropped #, , !!!, →
no tags →
the first appearance keeps its place work, Errands, #WORK, errands → work, errands
digits are kept and punctuation becomes a hyphen Q3 2026 plan, a_b, c++ → q3-2026-plan, a-b, c
Show the other 3 tests
CaseArgumentsExpected
a tag in a script slugify does not transliterate is dropped 日本, home → home
an emoji tag is dropped, one beside a word leaves the word 🏠, 🏠 home → home
already normal tags are unchanged home, bills-2026 → home, bills-2026

More from the author

Repeats are removed after normalising, keeping the first appearance, and the order is otherwise the order typed.

It does not enforce the limits (10 tags, 30 characters each): cutting a tag short or dropping the eleventh silently would lose what was typed. todo.item's validators report those, so the form can say so.

Files

PathBytes
README.md978
impl/python.py549
impl/rust.rs748
impl/typescript.ts671
vectors.json1,370