Functional Weave
Code in Rust

todo.parse-repeat

Read a Repeat box, "every 2 weeks" or "every monday on 2026-10-05", as a todo's recurrence, or null.

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

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

What it does

Reads what a person types into a todo's Repeat box as a `Recurrence` (todo.item), or null when it is not a repeat. It is the Repeat box's counterpart to todo.parse-date-phrase for the Due box.

`every 2 weeks` on a todo due Friday 2 October 2026 is `{frequency: weekly, interval: 2, anchor: 2026-10-02}`.

For example

  • parse_repeat(every week, —, 2026-09-28) → frequency weekly, interval 1, anchor 2026-09-28 every week with no due date starts today, as quick-add would
  • parse_repeat(every 2 weeks, 2026-10-02, 2026-09-28) → frequency weekly, interval 2, anchor 2026-10-02 every 2 weeks starts on the todo's due date
  • parse_repeat(every month, 2026-01-31, 2026-01-20) → frequency monthly, interval 1, anchor 2026-01-31 every month from a month-end due date keeps the 31st as the anchor

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 parse_repeat(text: &str, due: Option<&str>, today: &str) -> Option<Recurrence>
textstringwhat was typed: an "every ..." phrase from todo.parse-quick-add's grammar, then optionally a start date
duedate?the todo's due date, where the series starts unless the text names a date
todaydatethe person's local date, which relative phrases count from
returnsRecurrence?the recurrence, anchored on the series' first date (the todo's due date from then on); null when the text is not exactly that

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

fune!(todo.parse-repeat@^1);  // then call parse_repeat(…)
impl/rust.rs · 72 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::dates_add_days::add_days;  ← from dates.add-days ^1.0.0 · built alongside by fune
use super::dates_day_of_week::day_of_week;  ← from dates.day-of-week ^1.0.0 · built alongside by fune
use super::todo_item::Recurrence;  ← from todo.item ^1.0.0 · built alongside by fune
use super::todo_item_validate_draft::recurrence_to_value;
use super::todo_parse_date_phrase::parse_date_phrase;  ← from todo.parse-date-phrase ^1.0.0 · built alongside by fune
use super::todo_parse_quick_add::parse_quick_add;  ← from todo.parse-quick-add ^1.0.0 · built alongside by fune

/// A Repeat box: an "every ..." phrase as todo.parse-quick-add reads it, then
/// optionally a start date. Anything else is None. The series starts on the
/// typed date, else on `due`, else where quick-add would start it; "every
/// <weekday>" and "every weekday" move the start forward to a day they allow.
///
/// # Panics
/// On a malformed or impossible `today` or `due` (dates.add-days's message).
pub fn parse_repeat(text: &str, due: Option<&str>, today: &str) -> Option<Recurrence> {
    add_days(today, 0);
    if let Some(date) = due {
        add_days(date, 0);
    }
    let words: Vec<&str> = text
        .split(|c: char| matches!(c, ' ' | '\t' | '\n' | '\r' | '\u{0B}' | '\u{0C}'))
        .filter(|w| !w.is_empty())
        .collect();
    if words.len() < 2 || words[0].to_ascii_lowercase() != "every" {
        return None;
    }

    // The repeat is 2 words ("every week") or 3 ("every 2 weeks"); no 2-word
    // form starts a 3-word one, so the first that parses as a bare repeat wins.
    let mut found: Option<(Recurrence, usize)> = None;
    for n in [2usize, 3] {
        if n > words.len() {
            break;
        }
        let draft = parse_quick_add(&words[..n].join(" "), today);
        if let Some(rule) = draft.recurrence {
            if draft.title.is_empty() {
                found = Some((rule, n));
                break;
            }
        }
    }
    let (repeat, length) = found?;

    let mut start: Option<String> = due.map(|d| d.to_string());
    if words.len() > length {
        start = Some(parse_date_phrase(&words[length..].join(" "), today)?);
    }
    let mut start = match start {
        None => return Some(repeat),
        Some(date) => date,
    };

    if repeat.frequency == "weekdays" {
        let day = day_of_week(&start);
        if day >= 6 {
            start = add_days(&start, 8 - day);
        }
    } else if repeat.frequency == "weekly" && length == 2 && parse_date_phrase(words[1], today).is_some() {
        // "every monday": quick-add anchored it on the next Monday, so that
        // anchor's weekday is the one the series keeps.
        let shift = (day_of_week(&repeat.anchor) - day_of_week(&start) + 7) % 7;
        start = add_days(&start, shift);
    }
    Some(Recurrence { anchor: start, ..repeat })
}

pub fn fune_vector(args: &[Value]) -> Value {
    let due = if args[1].is_null() { None } else { Some(args[1].as_str()) };
    recurrence_to_value(&parse_repeat(args[0].as_str(), due, 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 5 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.parse-repeat
Download for Rust todo.parse-repeat-1.0.0-rust.fune · 11,519 bytes sha256 7e7069793b3be342cee847b57ad8c9307d08ce5b7935f41efab3e45b7b3b4efe

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

The whole function, every language, is one file too: todo.parse-repeat-1.0.0.fune, 16,279 bytes, sha256 efdd5ec36a56dc62104de85dc0453a6b348ac1d515dc1f5a5e1f6b7a9c048043. 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.parse-repeat

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

// fune: after todo.parse-repeat

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 dates.add-days in todo.parse-repeat
// fune: replace dates.day-of-week in todo.parse-repeat
// fune: replace todo.item in todo.parse-repeat
// fune: replace todo.parse-date-phrase in todo.parse-repeat
// fune: replace todo.parse-quick-add in todo.parse-repeat

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.parse-repeat --steps.

// fune: step todo.parse-repeat 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
every week with no due date starts today, as quick-add would every week, —, 2026-09-28 → frequency weekly, interval 1, anchor 2026-09-28
every 2 weeks starts on the todo's due date every 2 weeks, 2026-10-02, 2026-09-28 → frequency weekly, interval 2, anchor 2026-10-02
every month from a month-end due date keeps the 31st as the anchor every month, 2026-01-31, 2026-01-20 → frequency monthly, interval 1, anchor 2026-01-31
every Monday on a todo due Wednesday starts the next Monday, not weekly on Wednesdays Every Monday, 2026-09-30, 2026-09-28 → frequency weekly, interval 1, anchor 2026-10-05
every monday with no due date, typed on a Monday, starts next Monday every monday, —, 2026-09-28 → frequency weekly, interval 1, anchor 2026-10-05
every friday on a todo due that Friday starts that day every friday, 2026-10-02, 2026-09-28 → frequency weekly, interval 1, anchor 2026-10-02
every weekday on a todo due Saturday starts the Monday after every weekday, 2026-10-03, 2026-09-28 → frequency weekdays, interval 1, anchor 2026-10-05
every weekday typed on a Saturday with no due date starts Monday every weekday, —, 2026-10-03 → frequency weekdays, interval 1, anchor 2026-10-05
a date typed after the repeat beats the due date every other month on 2026-10-15, 2026-10-01, 2026-09-28 → frequency monthly, interval 2, anchor 2026-10-15
a relative start date counts from today every 3 days tomorrow, 2026-10-10, 2026-09-28 → frequency daily, interval 3, anchor 2026-09-29
Show the other 12 tests
CaseArgumentsExpected
a two-word start date every day next week, —, 2026-09-28 → frequency daily, interval 1, anchor 2026-10-05
a weekday repeat with a start date moves to that weekday on or after it every monday on wed, —, 2026-09-28 → frequency weekly, interval 1, anchor 2026-10-05
every year from 29 February keeps the leap day as the anchor every year, 2028-02-29, 2026-09-28 → frequency yearly, interval 1, anchor 2028-02-29
case and extra spaces do not matter EVERY day , 2026-10-01, 2026-09-28 → frequency daily, interval 1, anchor 2026-10-01
words left over are not understood every week please, 2026-10-01, 2026-09-28 → —
weekly on its own is a title word to quick-add, so not a repeat weekly, 2026-10-01, 2026-09-28 → —
an empty box is not a repeat , 2026-10-01, 2026-09-28 → —
every on its own is not a repeat every, —, 2026-09-28 → —
an interval above 999 is not a repeat every 1000 days, —, 2026-09-28 → —
a start date before the repeat is not understood tomorrow every week, —, 2026-09-28 → —
a malformed today is an error every week, —, 28/09/2026 → error: is not an ISO date
an impossible due date is an error every week, 2026-02-30, 2026-09-28 → error: is not a real calendar date

More from the author

## What it reads

The whole box must be one `every ...` phrase, then optionally a start date:

- **The repeat** is todo.parse-quick-add's grammar, exactly (the capability calls it rather than keeping a copy): `every day`, `every weekday`, `every week`, `every monday` (any weekday name), `every month`, `every year`, `every other week`, `every 3 days`, `every ten years` (1 to 999). Case-insensitive, ASCII whitespace between words. - **The start date**, if any, is whatever todo.parse-date-phrase reads as a whole: `tomorrow`, `on fri`, `next week`, `2026-10-15`. - **Anything else is null**: words left over (`every week please`), a date first (`tomorrow every week`), `weekly` on its own (a title word to quick-add), an empty box, `every 1000 days`. A form shows its own "try every week" message for null.

## Where the series starts

The anchor is the series' first date, and so the todo's due date from then on (todo.item refuses a due date before the anchor).

1. The date typed in the box, if there is one. 2. Otherwise `due`, the todo's due date. 3. Otherwise quick-add's choice: today; for `every weekday` today if it is Monday to Friday, else next Monday; for `every <weekday>` the next such day strictly after today.

Then, for steps 1 and 2, the start moves forward to the first day the pattern allows: `every monday` on a todo due Wednesday 30 September starts Monday 5 October (quick-add, by contrast, lets a date phrase in the same line win and repeats weekly on Wednesdays; in a Repeat box the weekday is the point), and `every weekday` from a Saturday starts the Monday after. A start already on an allowed day stays: `every friday` due Friday 2 October starts that day.

Month ends and leap days are left to todo.next-occurrence: `every month` from 31 January is anchored on the 31st, so it falls on each month's last day.

## Errors

Only a malformed or impossible `today` or `due` throws, with dates.add-days's message (`"28/09/2026" is not an ISO date`). Text that is not a repeat is null, never an error.

Files

PathBytes
README.md2,376
impl/python.py2,160
impl/rust.rs2,807
impl/typescript.ts2,368
vectors.json3,797