Functional Weave
Code in Rust

todo.item

The to-do item type (Todo, TodoDraft, Recurrence) and its validators, with a message per field.

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

Pinned by 51 tests, run in TypeScript, Python and Rust.validateDraft 35 · validateTodo 16

What it does

The shape of one to-do item, shared by every `todo.*` capability, and the two checks that say whether one is acceptable. `Todo` is what is stored; `TodoDraft` is the part a person edits (the add and edit forms, and what todo.parse-quick-add produces); `Recurrence` says how an item repeats.

`validateDraft` is for forms: it returns `valid` and one message per field that needs fixing, keyed by the field's name, so a form can show each message under its input and an API can return them as a validation error. It never throws. `validateTodo` is for stored items (read back from storage, or imported): the same rules plus the ones only a stored item has.

The functions

A group: 2 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. validate_draft (draft: TodoDraft) -> TodoValidation
  2. validate_todo (todo: Todo) -> TodoValidation

The types it declares, generated into your project

// Priority is a string in Rust, one of: "none", "low", "medium", "high".
// Parameters take it as &str and results hold it as String.

// RecurrenceFrequency is a string in Rust, one of: "daily", "weekdays", "weekly", "monthly", "yearly".
// Parameters take it as &str and results hold it as String.

/// How a todo repeats. Every occurrence is counted from the anchor, so month ends stay month ends.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Recurrence {
    pub frequency: String,
    /// every how many days, weeks, months or years, 1 to 999; always 1 for weekdays
    pub interval: i64,
    /// the first due date of the series
    pub anchor: String,
}

/// One to-do item as it is stored.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Todo {
    pub id: String,
    /// trimmed, one line, 1 to 200 characters
    pub title: String,
    /// up to 2000 characters
    pub notes: Option<String>,
    pub done: bool,
    pub priority: String,
    pub due: Option<String>,
    /// normalised with todo.normalise-tags: lower-case slugs, no duplicates, at most 10
    pub tags: Vec<String>,
    /// needs a due date
    pub recurrence: Option<Recurrence>,
    /// ISO 8601 UTC timestamp, e.g. 2026-09-28T09:30:00Z
    pub created_at: String,
    /// set exactly when done
    pub completed_at: Option<String>,
    /// manual position, 0 first
    pub order: i64,
}

/// The fields a person edits: what quick-add parses and the add or edit form submits.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TodoDraft {
    pub title: String,
    pub notes: Option<String>,
    pub priority: String,
    pub due: Option<String>,
    pub tags: Vec<String>,
    pub recurrence: Option<Recurrence>,
}

/// A todo's verdict, shaped for a form's field messages and an API's validation error.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TodoValidation {
    pub valid: bool,
    /// field name to message, in field order; empty when valid
    pub errors: Vec<(String, String)>,
}

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

validate_draft 35 tests

pub fn validate_draft(draft: &TodoDraft) -> TodoValidation
draftTodoDraftwhat the add or edit form holds, or what todo.parse-quick-add produced
returnsTodoValidationvalid, or a message for each field that needs fixing

For example

  • validate_draft(title Buy milk, notes —, priority none, due —, tags , recurrence —) → valid true, errors … a plain title and nothing else is valid
  • validate_draft(title Pay rent, notes Standing order failed, priority high, due 2026-10-01, tags home, bills-2026, recurrence …) → valid true, errors … every field filled in and consistent
  • validate_draft(title , notes —, priority none, due —, tags , recurrence —) → valid false, errors … a blank title (spaces only) needs a title
fune!(todo.item@^1);  // then call validate_draft(…)
impl/rust/validate_draft.rs · 226 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::days_in_month;  ← from dates.add-days ^1.0.0 · built alongside by fune

pub const MAX_TITLE: usize = 200;
pub const MAX_NOTES: usize = 2000;
pub const MAX_TAGS: usize = 10;
pub const MAX_TAG_LENGTH: usize = 30;
pub const MAX_INTERVAL: i64 = 999;

const PRIORITIES: [&str; 4] = ["none", "low", "medium", "high"];
const FREQUENCIES: [&str; 5] = ["daily", "weekdays", "weekly", "monthly", "yearly"];

fn is_ascii_space(ch: char) -> bool {
    matches!(ch, ' ' | '\t' | '\n' | '\r' | '\u{0B}' | '\u{0C}')
}

/// Trim ASCII whitespace only, so "trimmed" means the same in every language.
pub fn trim_space(text: &str) -> &str {
    text.trim_matches(is_ascii_space)
}

pub fn code_point_length(text: &str) -> usize {
    text.chars().count()
}

/// A real calendar date written YYYY-MM-DD, years 0001 to 9999. Never panics.
pub fn is_iso_date(text: &str) -> bool {
    let b = text.as_bytes();
    if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
        return false;
    }
    let digits = |from: usize, to: usize| b[from..to].iter().all(|c| c.is_ascii_digit());
    if !digits(0, 4) || !digits(5, 7) || !digits(8, 10) {
        return false;
    }
    let num = |from: usize, to: usize| b[from..to].iter().fold(0i64, |n, c| n * 10 + (c - b'0') as i64);
    let (year, month, day) = (num(0, 4), num(5, 7), num(8, 10));
    year >= 1 && (1..=12).contains(&month) && day >= 1 && day <= days_in_month(year, month)
}

fn is_tag_form(tag: &str) -> bool {
    let b = tag.as_bytes();
    if b.is_empty() || b[0] == b'-' || b[b.len() - 1] == b'-' {
        return false;
    }
    for (i, &c) in b.iter().enumerate() {
        let ok = c.is_ascii_lowercase() || c.is_ascii_digit() || (c == b'-' && b[i - 1] != b'-');
        if !ok {
            return false;
        }
    }
    true
}

fn title_error(title: &str) -> Option<String> {
    let trimmed = trim_space(title);
    if trimmed.is_empty() {
        return Some("Enter a title.".to_string());
    }
    if trimmed.chars().any(|ch| (ch as u32) < 0x20 || ch as u32 == 0x7F) {
        return Some("Keep the title to one line, without control characters.".to_string());
    }
    if code_point_length(trimmed) > MAX_TITLE {
        return Some(format!("Use no more than {} characters for the title.", MAX_TITLE));
    }
    None
}

fn tags_error(tags: &[String]) -> Option<String> {
    if tags.len() > MAX_TAGS {
        return Some(format!("Use no more than {} tags.", MAX_TAGS));
    }
    let mut seen: Vec<&str> = Vec::new();
    for tag in tags {
        if !is_tag_form(tag) {
            return Some(format!("Tag \"{}\" can only use lower-case letters, digits and single hyphens.", tag));
        }
        if tag.len() > MAX_TAG_LENGTH {
            return Some(format!("Tag \"{}\" is longer than {} characters.", tag, MAX_TAG_LENGTH));
        }
        if seen.contains(&tag.as_str()) {
            return Some(format!("Tag \"{}\" is listed twice.", tag));
        }
        seen.push(tag);
    }
    None
}

fn recurrence_error(rule: Option<&Recurrence>, due: Option<&str>) -> Option<String> {
    let rule = rule?;
    if !FREQUENCIES.contains(&rule.frequency.as_str()) {
        return Some("Choose how it repeats: daily, weekdays, weekly, monthly or yearly.".to_string());
    }
    if rule.interval < 1 || rule.interval > MAX_INTERVAL {
        return Some(format!("Repeat every 1 to {} days, weeks, months or years.", MAX_INTERVAL));
    }
    if rule.frequency == "weekdays" && rule.interval != 1 {
        return Some("Weekday repeats cannot skip weeks; use an interval of 1.".to_string());
    }
    if !is_iso_date(&rule.anchor) {
        return Some("Enter the repeat start as a real date, YYYY-MM-DD.".to_string());
    }
    match due {
        None => Some("A repeating todo needs a due date.".to_string()),
        Some(d) if is_iso_date(d) && rule.anchor.as_str() > d => {
            Some("The repeat cannot start after the due date.".to_string())
        }
        _ => None,
    }
}

/// The edit rules as ordered (field, message) pairs, from the draft's fields,
/// so validate_todo can run them on a stored Todo too.
pub fn draft_errors(
    title: &str,
    notes: Option<&str>,
    priority: &str,
    due: Option<&str>,
    tags: &[String],
    recurrence: Option<&Recurrence>,
) -> Vec<(String, String)> {
    let mut errors: Vec<(String, String)> = Vec::new();
    if let Some(message) = title_error(title) {
        errors.push(("title".to_string(), message));
    }
    if let Some(n) = notes {
        if code_point_length(n) > MAX_NOTES {
            errors.push(("notes".to_string(), format!("Use no more than {} characters for the notes.", MAX_NOTES)));
        }
    }
    if !PRIORITIES.contains(&priority) {
        errors.push(("priority".to_string(), "Choose a priority: none, low, medium or high.".to_string()));
    }
    if let Some(d) = due {
        if !is_iso_date(d) {
            errors.push(("due".to_string(), "Enter the due date as a real date, YYYY-MM-DD.".to_string()));
        }
    }
    if let Some(message) = tags_error(tags) {
        errors.push(("tags".to_string(), message));
    }
    if let Some(message) = recurrence_error(recurrence, due) {
        errors.push(("recurrence".to_string(), message));
    }
    errors
}

/// Check what a person typed into the add or edit form; the title is judged trimmed.
pub fn validate_draft(draft: &TodoDraft) -> TodoValidation {
    let errors = draft_errors(
        &draft.title,
        draft.notes.as_deref(),
        &draft.priority,
        draft.due.as_deref(),
        &draft.tags,
        draft.recurrence.as_ref(),
    );
    TodoValidation { valid: errors.is_empty(), errors }
}

fn opt_text(v: &Value) -> Option<String> {
    if v.is_null() { None } else { Some(v.as_str().to_string()) }
}

fn opt_value(v: &Option<String>) -> Value {
    v.as_deref().map(Value::str).unwrap_or(Value::Null)
}

pub fn texts_from_value(v: &Value) -> Vec<String> {
    v.as_arr().iter().map(|t| t.as_str().to_string()).collect()
}

pub fn recurrence_from_value(v: &Value) -> Option<Recurrence> {
    if v.is_null() {
        return None;
    }
    Some(Recurrence {
        frequency: v.get("frequency").as_str().to_string(),
        interval: v.get("interval").as_i64(),
        anchor: v.get("anchor").as_str().to_string(),
    })
}

pub fn recurrence_to_value(rule: &Option<Recurrence>) -> Value {
    match rule {
        None => Value::Null,
        Some(r) => Value::obj(vec![
            ("frequency", Value::str(&r.frequency)),
            ("interval", Value::Int(r.interval)),
            ("anchor", Value::str(&r.anchor)),
        ]),
    }
}

pub fn todo_draft_from_value(v: &Value) -> TodoDraft {
    TodoDraft {
        title: v.get("title").as_str().to_string(),
        notes: opt_text(v.get("notes")),
        priority: v.get("priority").as_str().to_string(),
        due: opt_text(v.get("due")),
        tags: texts_from_value(v.get("tags")),
        recurrence: recurrence_from_value(v.get("recurrence")),
    }
}

pub fn todo_draft_to_value(draft: &TodoDraft) -> Value {
    Value::obj(vec![
        ("title", Value::str(&draft.title)),
        ("notes", opt_value(&draft.notes)),
        ("priority", Value::str(&draft.priority)),
        ("due", opt_value(&draft.due)),
        ("tags", Value::Arr(draft.tags.iter().map(|t| Value::str(t)).collect())),
        ("recurrence", recurrence_to_value(&draft.recurrence)),
    ])
}

pub fn todo_validation_to_value(result: &TodoValidation) -> Value {
    Value::obj(vec![
        ("valid", Value::Bool(result.valid)),
        ("errors", Value::Obj(result.errors.iter().map(|(k, m)| (k.clone(), Value::str(m))).collect())),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    todo_validation_to_value(&validate_draft(&todo_draft_from_value(&args[0])))
}

validate_todo 16 tests

pub fn validate_todo(todo: &Todo) -> TodoValidation
todoTodoa stored item, e.g. one read back from storage or an import
returnsTodoValidationthe draft rules plus id, timestamps, completion and order

For example

  • validate_todo(id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at —, order 0) → valid true, errors … a stored open todo
  • validate_todo(id t1, title Buy milk, notes —, done true, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at 2026-09-28T18:00:00Z, order 0) → valid true, errors … a stored done todo with its completion time
  • validate_todo(id a-1, title Pay rent, notes via bank, done true, priority high, due 2026-10-01, tags home, recurrence …, created at 2026-09-28T09:30:00.123Z, completed at 2026-10-01T07:00:00.5Z…) → valid true, errors … a full stored todo with fractional-second timestamps, as JavaScript writes them
fune!(todo.item@^1);  // then call validate_todo(…)
impl/rust/validate_todo.rs · 135 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::{
    draft_errors, is_iso_date, recurrence_from_value, recurrence_to_value, texts_from_value, todo_validation_to_value,
    trim_space,
};

const FIELD_ORDER: [&str; 10] = [
    "id", "title", "notes", "priority", "due", "tags", "recurrence", "createdAt", "completedAt", "order",
];

/// YYYY-MM-DDTHH:MM:SS, an optional 1-9 digit fraction, then Z.
pub fn is_utc_timestamp(text: &str) -> bool {
    let b = text.as_bytes();
    if b.len() < 20 || !text.is_char_boundary(10) || !is_iso_date(&text[0..10]) {
        return false;
    }
    if b[10] != b'T' || b[13] != b':' || b[16] != b':' {
        return false;
    }
    let two = |at: usize, most: u32| {
        b[at].is_ascii_digit() && b[at + 1].is_ascii_digit() && ((b[at] - b'0') as u32 * 10 + (b[at + 1] - b'0') as u32) <= most
    };
    if !two(11, 23) || !two(14, 59) || !two(17, 59) {
        return false;
    }
    let mut rest = &b[19..];
    if rest.first() == Some(&b'.') {
        let mut n = 1;
        while n < rest.len() && rest[n].is_ascii_digit() {
            n += 1;
        }
        if n == 1 || n > 10 {
            return false;
        }
        rest = &rest[n..];
    }
    rest == b"Z"
}

/// Check a stored todo: the draft rules plus id, trimmed title, timestamps,
/// completion and order.
pub fn validate_todo(todo: &Todo) -> TodoValidation {
    let mut found = draft_errors(
        &todo.title,
        todo.notes.as_deref(),
        &todo.priority,
        todo.due.as_deref(),
        &todo.tags,
        todo.recurrence.as_ref(),
    );
    let has_title_error = found.iter().any(|(f, _)| f == "title");
    let mut set = |field: &str, message: &str| {
        found.retain(|(f, _)| f != field);
        found.push((field.to_string(), message.to_string()));
    };
    if trim_space(&todo.id).is_empty() {
        set("id", "Every todo needs an id.");
    }
    if !has_title_error && trim_space(&todo.title) != todo.title {
        set("title", "Remove the spaces around the title.");
    }
    if !is_utc_timestamp(&todo.created_at) {
        set("createdAt", "Record when it was created as a UTC timestamp, e.g. 2026-09-28T09:30:00Z.");
    }
    match (todo.done, todo.completed_at.as_deref()) {
        (true, None) => set("completedAt", "A done todo needs the time it was completed."),
        (false, Some(_)) => set("completedAt", "An open todo cannot have a completion time."),
        (_, Some(t)) if !is_utc_timestamp(t) => {
            set("completedAt", "Record when it was completed as a UTC timestamp, e.g. 2026-09-28T09:30:00Z.")
        }
        _ => {}
    }
    if todo.order < 0 {
        set("order", "Order must be a whole number, 0 or more.");
    }
    let mut errors: Vec<(String, String)> = Vec::new();
    for field in FIELD_ORDER {
        if let Some((f, m)) = found.iter().find(|(f, _)| f == field) {
            errors.push((f.clone(), m.clone()));
        }
    }
    TodoValidation { valid: errors.is_empty(), errors }
}

fn opt_text(v: &Value) -> Option<String> {
    if v.is_null() { None } else { Some(v.as_str().to_string()) }
}

fn opt_value(v: &Option<String>) -> Value {
    v.as_deref().map(Value::str).unwrap_or(Value::Null)
}

pub fn todo_from_value(v: &Value) -> Todo {
    Todo {
        id: v.get("id").as_str().to_string(),
        title: v.get("title").as_str().to_string(),
        notes: opt_text(v.get("notes")),
        done: v.get("done").as_bool(),
        priority: v.get("priority").as_str().to_string(),
        due: opt_text(v.get("due")),
        tags: texts_from_value(v.get("tags")),
        recurrence: recurrence_from_value(v.get("recurrence")),
        created_at: v.get("createdAt").as_str().to_string(),
        completed_at: opt_text(v.get("completedAt")),
        order: v.get("order").as_i64(),
    }
}

pub fn todos_from_value(v: &Value) -> Vec<Todo> {
    v.as_arr().iter().map(todo_from_value).collect()
}

pub fn todo_to_value(todo: &Todo) -> Value {
    Value::obj(vec![
        ("id", Value::str(&todo.id)),
        ("title", Value::str(&todo.title)),
        ("notes", opt_value(&todo.notes)),
        ("done", Value::Bool(todo.done)),
        ("priority", Value::str(&todo.priority)),
        ("due", opt_value(&todo.due)),
        ("tags", Value::Arr(todo.tags.iter().map(|t| Value::str(t)).collect())),
        ("recurrence", recurrence_to_value(&todo.recurrence)),
        ("createdAt", Value::str(&todo.created_at)),
        ("completedAt", opt_value(&todo.completed_at)),
        ("order", Value::Int(todo.order)),
    ])
}

pub fn todos_to_value(todos: &[Todo]) -> Value {
    Value::Arr(todos.iter().map(todo_to_value).collect())
}

pub fn fune_vector(args: &[Value]) -> Value {
    todo_validation_to_value(&validate_todo(&todo_from_value(&args[0])))
}

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.item

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

fune add todo.item --only validateDraft
Download for Rust todo.item-1.0.0-rust.fune · 48,998 bytes sha256 063f7a6998e24abbaf4873ed0a0fc3659c5ca0baa73bcc450273184fd495edb1

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

The whole function, every language, is one file too: todo.item-1.0.0.fune, 65,295 bytes, sha256 3b6af46b7431804010e2f024169dc8d1e700829887069428d844cbc5f8d9e295. 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.item.validateDraft
// fune: before todo.item.validateTodo

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

// fune: after todo.item.validateDraft
// fune: after todo.item.validateTodo

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.item

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.item --steps.

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

validateDraft 35 tests

CaseArgumentsExpected
a plain title and nothing else is valid title Buy milk, notes —, priority none, due —, tags , recurrence — → valid true, errors …
every field filled in and consistent title Pay rent, notes Standing order failed, priority high, due 2026-10-01, tags home, bills-2026, recurrence … → valid true, errors …
a blank title (spaces only) needs a title title , notes —, priority none, due —, tags , recurrence — → valid false, errors …
an empty title needs a title title , notes —, priority none, due —, tags , recurrence — → valid false, errors …
spaces around the title are fine in a draft: it is judged trimmed title Buy milk , notes —, priority none, due —, tags , recurrence — → valid true, errors …
200 characters is the longest title title aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… → valid true, errors …
201 characters is too long title aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… → valid false, errors …
200 emoji is 200 characters, not 400 UTF-16 units or 800 bytes title 😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀�… → valid true, errors …
201 accented letters count as 201, not as 402 bytes title ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé… → valid false, errors …
spaces do not count towards the length title aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… → valid true, errors …
Show the other 25 tests
CaseArgumentsExpected
a line break inside the title is refused title Buy milk, notes —, priority none, due —, tags , recurrence — → valid false, errors …
a trailing line break is trimmed away, not refused title Buy milk , notes —, priority none, due —, tags , recurrence — → valid true, errors …
2000 characters of notes is allowed title Buy milk, notes nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn… → valid true, errors …
2001 characters of notes is too long title Buy milk, notes nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn… → valid false, errors …
an unknown priority title Buy milk, notes —, priority urgent, due —, tags , recurrence — → valid false, errors …
a due date that never existed title Buy milk, notes —, priority none, due 2026-02-30, tags , recurrence — → valid false, errors …
29 February in a leap year is a real date title Buy milk, notes —, priority none, due 2028-02-29, tags , recurrence — → valid true, errors …
a due date with a trailing newline is not a date title Buy milk, notes —, priority none, due 2026-09-28 , tags , recurrence — → valid false, errors …
a due date in Arabic-Indic digits is not a date title Buy milk, notes —, priority none, due ٢٠٢٦-09-28, tags , recurrence — → valid false, errors …
a tag with a capital letter is not in normal form title Buy milk, notes —, priority none, due —, tags Home, recurrence — → valid false, errors …
a tag with a # is not in normal form title Buy milk, notes —, priority none, due —, tags #home, recurrence — → valid false, errors …
a double hyphen is not in normal form title Buy milk, notes —, priority none, due —, tags a--b, recurrence — → valid false, errors …
the same tag twice title Buy milk, notes —, priority none, due —, tags home, work, home, recurrence — → valid false, errors …
ten tags is the most title Buy milk, notes —, priority none, due —, tags t0, t1, t2, t3, t4, t5, t6, t7, t8, t9, recurrence — → valid true, errors …
eleven tags is too many title Buy milk, notes —, priority none, due —, tags t0, t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, recurrence — → valid false, errors …
a 31-character tag is too long; 30 is fine title Buy milk, notes —, priority none, due —, tags aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb, recurrence — → valid false, errors …
a repeat needs a due date title Buy milk, notes —, priority none, due —, tags , recurrence … → valid false, errors …
weekday repeats cannot skip weeks title Buy milk, notes —, priority none, due 2026-09-28, tags , recurrence … → valid false, errors …
an interval of 0 repeats nothing title Buy milk, notes —, priority none, due 2026-09-28, tags , recurrence … → valid false, errors …
999 is the largest interval title Buy milk, notes —, priority none, due 2026-09-28, tags , recurrence … → valid true, errors …
an unknown frequency title Buy milk, notes —, priority none, due 2026-09-28, tags , recurrence … → valid false, errors …
a repeat start that is not a date title Buy milk, notes —, priority none, due 2026-09-28, tags , recurrence … → valid false, errors …
a repeat cannot start after the due date title Buy milk, notes —, priority none, due 2026-09-28, tags , recurrence … → valid false, errors …
a later occurrence of a series started earlier is fine title Buy milk, notes —, priority none, due 2026-11-30, tags , recurrence … → valid true, errors …
several problems are all reported, one message per field title , notes nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn… → valid false, errors …

validateTodo 16 tests

CaseArgumentsExpected
a stored open todo id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at —, order 0 → valid true, errors …
a stored done todo with its completion time id t1, title Buy milk, notes —, done true, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at 2026-09-28T18:00:00Z, order 0 → valid true, errors …
a full stored todo with fractional-second timestamps, as JavaScript writes them id a-1, title Pay rent, notes via bank, done true, priority high, due 2026-10-01, tags home, recurrence …, created at 2026-09-28T09:30:00.123Z, completed at 2026-10-01T07:00:00.5Z… → valid true, errors …
a done todo without a completion time id t1, title Buy milk, notes —, done true, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at —, order 0 → valid false, errors …
an open todo cannot have a completion time id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at 2026-09-28T18:00:00Z, order 0 → valid false, errors …
a malformed completion time id t1, title Buy milk, notes —, done true, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at yesterday, order 0 → valid false, errors …
a stored title must already be trimmed id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at —, order 0 → valid false, errors …
a blank stored title says enter a title, not remove the spaces id t1, title , notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at —, order 0 → valid false, errors …
an empty id id , title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at —, order 0 → valid false, errors …
a created time with a space instead of T id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28 09:30:00Z, completed at —, order 0 → valid false, errors …
Show the other 6 tests
CaseArgumentsExpected
a created time with an offset instead of Z id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00+01:00, completed at —, order 0 → valid false, errors …
hour 24 is not a time id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T24:00:00Z, completed at —, order 0 → valid false, errors …
a created time on 30 February id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-02-30T09:30:00Z, completed at —, order 0 → valid false, errors …
a fraction of ten digits is too precise id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00.1234567890Z, completed at —, order 0 → valid false, errors …
a negative order id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at —, order -1 → valid false, errors …
the draft rules apply to a stored todo, and every message comes back id , title , notes —, done false, priority none, due —, tags home, home, recurrence —, created at , completed at —, order -2 → valid false, errors …

More from the author

## The rules

- **title**: judged after trimming, 1 to 200 characters, one line (no control characters). In a stored todo it must already be trimmed. - **notes**: optional, up to 2000 characters. - **priority**: `none`, `low`, `medium` or `high`. - **due**: optional, a real calendar date `YYYY-MM-DD`. A due date has no time of day: a to-do is due on a day. - **tags**: at most 10, each in the normal form todo.normalise-tags produces (lower-case ASCII letters and digits, runs joined by single hyphens), at most 30 characters, no duplicates. The first problem is reported. - **recurrence**: optional; frequency `daily`, `weekdays`, `weekly`, `monthly` or `yearly`, interval 1 to 999 (weekdays only 1), a real `anchor` date, and it needs a due date no earlier than the anchor. - **id** (stored): not blank. Ids are the caller's: pass one in. - **createdAt**, **completedAt** (stored): UTC timestamps `YYYY-MM-DDTHH:MM:SS[.fraction]Z`, as JavaScript's `toISOString` writes them. Offsets are refused so that timestamps compare as strings. `completedAt` is set exactly when `done` is true. - **order** (stored): the manual position, a whole number 0 or more.

## Decisions

"Characters" are Unicode code points, which all three languages count the same way: 200 emoji is 200 characters, not 400 UTF-16 units.

Trimming removes ASCII whitespace only (space, tab, CR, LF, VT, FF). The three standard libraries trim different sets of Unicode spaces, so a wider trim would store different titles in different services.

The anchor is why month ends survive: a monthly todo anchored on 31 January is due 28 February, then 31 March, because every occurrence is counted from the anchor (todo.next-occurrence). Without it, the 28 February copy would drift to the 28th for ever.

Messages are full sentences written for the person at the form, and the same in every language.

Files

PathBytes
README.md2,552
impl/python/validate_draft.py4,564
impl/python/validate_todo.py2,388
impl/rust/validate_draft.rs7,775
impl/rust/validate_todo.rs4,799
impl/typescript/validate_draft.ts5,661
impl/typescript/validate_todo.ts2,802
vectors.json24,788