Functional Weave
Code in Rust

todo.import-csv

Read todos from a CSV file (RFC 4180) as todo.export-csv writes it, checking every row, with row-numbered errors.

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

Pinned by 59 tests, run in TypeScript, Python and Rust.importCsv 46 · validateTodoCsv 13

What it does

Reads todos from CSV text, as todo.export-csv writes it or as a spreadsheet saves it, and checks every row. It returns the todos in file order, each one valid by todo.item's `validateTodo`, or throws on the first problem with the row it is in.

## The file format

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. import_csv (csv: string) -> Todo[]
  2. validate_todo_csv (csv: string) -> CsvValidation

The types it declares, generated into your project

/// One problem in a todo CSV file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CsvRowError {
    /// the record it is in, counting the header as row 1, as a spreadsheet numbers rows
    pub row: i64,
    /// the todo field or CSV column it is about; null when the row as a whole is wrong
    pub field: Option<String>,
    pub message: String,
}

/// Every problem in a todo CSV file, so a screen can list them all at once.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CsvValidation {
    pub valid: bool,
    /// todo rows read, not counting the header
    pub rows: i64,
    /// in file order, and in check order within a row; empty when valid
    pub errors: Vec<CsvRowError>,
}

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

import_csv throws on bad input 46 tests

pub fn import_csv(csv: &str) -> Vec<Todo>
csvstringthe file's text; a UTF-8 byte order mark and LF line endings are accepted
returnsTodo[]the todos in file order, each one valid by todo.item's validateTodo

For example

  • import_csv(id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order ) → the header alone is an empty list
  • import_csv(id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order) → the header without a final line break
  • import_csv(id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 ) → ×1 one plain row: empty fields become null, false, order 0
fune!(todo.import-csv@^1);  // then call import_csv(…)
impl/rust/import_csv.rs · 22 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_import_csv_validate_todo_csv::read_todo_csv;  ← validateTodoCsv, another function of this group · built into the same file, even by a slim install
use super::todo_item::{todos_to_value, Todo};  ← from todo.item ^1.0.0 · built alongside by fune

/// Todos from CSV: the 13 columns in any order, CRLF or LF, an optional
/// byte order mark. Every row is checked with validate_todo.
///
/// # Panics
/// With "row N: ..." (the header is row 1) on the first malformed or invalid
/// row, or on a header without exactly the 13 columns. validate_todo_csv
/// lists every problem instead.
pub fn import_csv(csv: &str) -> Vec<Todo> {
    let reading = read_todo_csv(csv);
    if let Some(first) = reading.problems.first() {
        panic!("{}", first.thrown);
    }
    reading.todos
}

pub fn fune_vector(args: &[Value]) -> Value {
    todos_to_value(&import_csv(args[0].as_str()))
}

validate_todo_csv 13 tests

pub fn validate_todo_csv(csv: &str) -> CsvValidation
csvstringthe same text importCsv reads
returnsCsvValidationevery problem in the file, row by row, instead of only the first

For example

  • validate_todo_csv(id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0 t2…) → valid true, rows 2, errors a good file is valid and counts its rows
  • validate_todo_csv(id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order ) → valid true, rows 0, errors a header with no rows is a valid, empty file
  • validate_todo_csv() → valid false, rows 0, errors ×1 empty text has no header row
fune!(todo.import-csv@^1);  // then call validate_todo_csv(…)
impl/rust/validate_todo_csv.rs · 281 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 std::collections::HashSet;

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_todo, Recurrence, Todo};  ← from todo.item ^1.0.0 · built alongside by fune
use super::todo_normalise_tags::normalise_tags;  ← from todo.normalise-tags ^1.0.0 · built alongside by fune

const COLUMNS: [&str; 13] = [
    "id", "title", "notes", "done", "priority", "due", "tags",
    "recurrenceFrequency", "recurrenceInterval", "recurrenceAnchor", "createdAt", "completedAt", "order",
];

/// One problem, and the message import_csv panics with when it is the first.

pub struct TodoCsvProblem {
    pub error: CsvRowError,
    pub thrown: String,
}

/// What reading a whole file found: the good rows' todos, and every problem.
pub struct TodoCsvReading {
    pub todos: Vec<Todo>,
    pub rows: i64,
    pub problems: Vec<TodoCsvProblem>,
}

fn header_rule() -> String {
    format!("the header must have the columns {}", COLUMNS.join(", "))
}

/// One RFC 4180 record from `pos`: its fields and where the next record
/// starts, or why it cannot be read. Only ASCII bytes are looked at, so every
/// slice is valid UTF-8.
fn record_at(text: &str, mut pos: usize) -> Result<(Vec<String>, usize), String> {
    let b = text.as_bytes();
    let size = b.len();
    let mut fields: Vec<String> = Vec::new();
    loop {
        let value: String;
        if pos < size && b[pos] == b'"' {
            pos += 1;
            let mut out = String::new();
            loop {
                let quote = match text[pos..].find('"') {
                    Some(q) => pos + q,
                    None => return Err("a quoted field is not closed".to_string()),
                };
                out.push_str(&text[pos..quote]);
                if quote + 1 < size && b[quote + 1] == b'"' {
                    out.push('"');
                    pos = quote + 2;
                } else {
                    pos = quote + 1;
                    break;
                }
            }
            if pos < size && !matches!(b[pos], b',' | b'\r' | b'\n') {
                return Err("text after the closing quote of a field".to_string());
            }
            value = out;
        } else {
            let mut end = pos;
            while end < size && !matches!(b[end], b',' | b'\r' | b'\n') {
                if b[end] == b'"' {
                    return Err("a field with a double quote in it must be quoted".to_string());
                }
                end += 1;
            }
            value = text[pos..end].to_string();
            pos = end;
        }
        fields.push(value);
        if pos >= size {
            return Ok((fields, pos));
        }
        if b[pos] == b',' {
            pos += 1;
        } else if b[pos] == b'\n' {
            return Ok((fields, pos + 1));
        } else if pos + 1 < size && b[pos + 1] == b'\n' {
            return Ok((fields, pos + 2));
        } else {
            return Err("a CR outside quotes must be followed by LF".to_string());
        }
    }
}

/// Where each of COLUMNS sits in the file's header, or what is wrong with it.
fn columns_at(names: &[String]) -> Result<Vec<usize>, String> {
    let mut at: Vec<(&str, usize)> = Vec::new();
    for (i, name) in names.iter().enumerate() {
        if !COLUMNS.contains(&name.as_str()) {
            return Err(format!("{}: unknown column \"{}\"", header_rule(), name));
        }
        if at.iter().any(|(n, _)| n == name) {
            return Err(format!("{}: column \"{}\" appears twice", header_rule(), name));
        }
        at.push((name.as_str(), i));
    }
    let mut out = Vec::new();
    for column in COLUMNS.iter() {
        match at.iter().find(|(n, _)| n == column) {
            Some((_, i)) => out.push(*i),
            None => return Err(format!("{}: missing column \"{}\"", header_rule(), column)),
        }
    }
    Ok(out)
}

fn whole_number(text: &str) -> Option<i64> {
    let digits = text.strip_prefix('-').unwrap_or(text);
    if digits.is_empty() || digits.len() > 15 || !digits.bytes().all(|c| c.is_ascii_digit()) {
        return None;
    }
    Some(text.parse::<i64>().unwrap())
}

fn opt(text: &str) -> Option<String> {
    if text.is_empty() { None } else { Some(text.to_string()) }
}

fn problem(row: i64, field: Option<&str>, message: &str, thrown: String) -> TodoCsvProblem {
    TodoCsvProblem {
        error: CsvRowError { row, field: field.map(|f| f.to_string()), message: message.to_string() },
        thrown,
    }
}

/// Reads the whole file, collecting every problem; the first is the one
/// import_csv 1.0.0 panicked with. A header or a record that cannot be split
/// into fields ends the reading.
pub fn read_todo_csv(csv: &str) -> TodoCsvReading {
    let text = csv.strip_prefix('\u{feff}').unwrap_or(csv);
    let b = text.as_bytes();
    let mut todos: Vec<Todo> = Vec::new();
    let mut problems: Vec<TodoCsvProblem> = Vec::new();
    let mut ids: HashSet<String> = HashSet::new();
    let mut at: Option<Vec<usize>> = None;
    let mut row: i64 = 0;
    let mut rows: i64 = 0;
    let mut pos = 0;
    while pos < b.len() {
        if b[pos] == b'\n' {
            pos += 1;
            continue;
        }
        if b[pos] == b'\r' && pos + 1 < b.len() && b[pos + 1] == b'\n' {
            pos += 2;
            continue;
        }
        row += 1;
        let fields = match record_at(text, pos) {
            Err(message) => {
                problems.push(problem(row, None, &message, format!("row {}: {}", row, message)));
                break;
            }
            Ok((fields, next)) => {
                pos = next;
                fields
            }
        };
        let columns = match &at {
            None => match columns_at(&fields) {
                Err(message) => {
                    problems.push(problem(row, None, &message, message.clone()));
                    break;
                }
                Ok(columns) => {
                    at = Some(columns);
                    continue;
                }
            },
            Some(columns) => columns.clone(),
        };
        rows += 1;
        if fields.len() != COLUMNS.len() {
            let message = format!("expected {} fields, found {}", COLUMNS.len(), fields.len());
            problems.push(problem(row, None, &message, format!("row {}: {}", row, message)));
            continue;
        }
        let before = problems.len();
        let plain = |problems: &mut Vec<TodoCsvProblem>, field: &str, message: String| {
            problems.push(problem(row, Some(field), &message, format!("row {}: {}", row, message)));
        };
        let get = |i: usize| fields[columns[i]].as_str();
        let done = get(3);
        let done_read = done == "true" || done == "false";
        if !done_read {
            plain(&mut problems, "done", format!("done must be true or false, found \"{}\"", done));
        }
        let (frequency, interval, anchor) = (get(7), get(8), get(9));
        let filled = [frequency, interval, anchor].iter().filter(|t| !t.is_empty()).count();
        let mut recurrence: Option<Recurrence> = None;
        let mut recurrence_read = true;
        if filled == 3 {
            match whole_number(interval) {
                None => {
                    plain(&mut problems, "recurrenceInterval", format!("recurrenceInterval must be a whole number, found \"{}\"", interval));
                    recurrence_read = false;
                }
                Some(every) => {
                    recurrence = Some(Recurrence { frequency: frequency.to_string(), interval: every, anchor: anchor.to_string() });
                }
            }
        } else if filled != 0 {
            plain(&mut problems, "recurrence", "fill in all three recurrence columns or leave them all empty".to_string());
            recurrence_read = false;
        }
        let order = get(12);
        let position = whole_number(order);
        if position.is_none() {
            plain(&mut problems, "order", format!("order must be a whole number, found \"{}\"", order));
        }
        let tags: Vec<String> = get(6).split(' ').map(|t| t.to_string()).collect();
        let todo = Todo {
            id: get(0).to_string(),
            title: get(1).to_string(),
            notes: opt(get(2)),
            done: done == "true",
            priority: get(4).to_string(),
            due: opt(get(5)),
            tags: normalise_tags(&tags),
            recurrence,
            created_at: get(10).to_string(),
            completed_at: opt(get(11)),
            order: position.unwrap_or(0),
        };
        // A column that could not be read has already been reported; the
        // stand-in value must not raise a second, misleading message.
        let errors = validate_todo(&todo).errors;
        for (field, message) in errors.iter() {
            if (field == "completedAt" && !done_read) || (field == "recurrence" && !recurrence_read) || (field == "order" && position.is_none()) {
                continue;
            }
            problems.push(problem(row, Some(field.as_str()), message, format!("row {}: {}: {}", row, field, message)));
        }
        if !errors.iter().any(|(field, _)| field == "id") {
            if ids.contains(&todo.id) {
                plain(&mut problems, "id", format!("duplicate id \"{}\"", todo.id));
            }
            ids.insert(todo.id.clone());
        }
        if problems.len() == before {
            todos.push(todo);
        }
    }
    if at.is_none() && problems.is_empty() {
        let message = "the CSV has no header row";
        problems.push(problem(1, None, message, message.to_string()));
    }
    TodoCsvReading { todos, rows, problems }
}

/// Every problem in a todo CSV file, row by row: the row (header = 1), the
/// field or column, and the message. Valid exactly when import_csv accepts it.
pub fn validate_todo_csv(csv: &str) -> CsvValidation {
    let reading = read_todo_csv(csv);
    CsvValidation {
        valid: reading.problems.is_empty(),
        rows: reading.rows,
        errors: reading.problems.into_iter().map(|p| p.error).collect(),
    }
}

pub fn csv_validation_to_value(result: &CsvValidation) -> Value {
    let errors = result
        .errors
        .iter()
        .map(|e| {
            Value::obj(vec![
                ("row", Value::Int(e.row)),
                ("field", e.field.as_deref().map(Value::str).unwrap_or(Value::Null)),
                ("message", Value::str(&e.message)),
            ])
        })
        .collect();
    Value::obj(vec![("valid", Value::Bool(result.valid)), ("rows", Value::Int(result.rows)), ("errors", Value::Arr(errors))])
}

pub fn fune_vector(args: &[Value]) -> Value {
    csv_validation_to_value(&validate_todo_csv(args[0].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 2 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.import-csv

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

fune add todo.import-csv --only importCsv
Download for Rust todo.import-csv-1.1.0-rust.fune · 50,545 bytes sha256 73280bc5b26fd62a6f37c94c55c55cdf58f424af5e5c1a551c87d4e3bbfff4fa

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

The whole function, every language, is one file too: todo.import-csv-1.1.0.fune, 67,710 bytes, sha256 b929c1dc7a055e568d43fe74cd107a4a7846eb0bd4b458f321a6feb040f764b1. 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.import-csv.importCsv
// fune: before todo.import-csv.validateTodoCsv

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

// fune: after todo.import-csv.importCsv
// fune: after todo.import-csv.validateTodoCsv

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 todo.item in todo.import-csv
// fune: replace todo.normalise-tags in todo.import-csv

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.import-csv --steps.

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

importCsv 46 tests

CaseArgumentsExpected
the header alone is an empty list id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order →
the header without a final line break id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order →
one plain row: empty fields become null, false, order 0 id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 → ×1
the last row without a final line break id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 → ×1
LF line endings and a UTF-8 byte order mark id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 → ×1
columns in another order are matched by name order,completedAt,createdAt,recurrenceAnchor,recurrenceInterval,recurrenceFrequency,tags,due,priority,done,notes,title,id 7,2026-09-28T10:00:00Z,2026-09-20T08:00:00Z,2026-10-01,2… → ×1
round trip: the file todo.export-csv writes for quotes, commas, line breaks, unicode, a repeat and a done todo id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,"Read ""Dune"", then lend it","Chapter 1 Chapter 2 s… → ×2
empty lines between and after rows are skipped id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 … → ×2
tags from another tool are normalised: # dropped, lower-cased, commas and repeats gone id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,"#Home Work, home Home-Office"… → ×1
quoted fields that did not need quoting, and an empty quoted field for notes (null) id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order "t1","Buy milk","","false","none","","","","","","2026-0… → ×1
Show the other 36 tests
CaseArgumentsExpected
a lone CR inside quotes is kept as it is id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,"a b",false,none,,,,,,2026-09-28T09:30:00Z,,… → ×1
leading zeros in order and interval are read as the number id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,2026-10-01,,daily,003,2026-10-01… → ×1
spaces around a field are data, not trimmed (notes keep them) id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk, note ,false,none,,,,,,2026-09-28T09:30:00… → ×1
the same title twice is fine; only ids must differ id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 t2… → ×2
empty text has no header row → error: the CSV has no header row
only empty lines and a byte order mark have no header row  → error: the CSV has no header row
a header without the order column id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt → error: the header must have the columns id, title, notes, done, priority, due, tags, recurrenceFrequency, recurrenceInterval, recurrenceAnchor, createdAt, completedAt, order: missing column "order"
a header with an extra column id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order,colour → error: the header must have the columns id, title, notes, done, priority, due, tags, recurrenceFrequency, recurrenceInterval, recurrenceAnchor, createdAt, completedAt, order: unknown column "colour"
header names are matched exactly: Title is not title id,Title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order → error: the header must have the columns id, title, notes, done, priority, due, tags, recurrenceFrequency, recurrenceInterval, recurrenceAnchor, createdAt, completedAt, order: unknown column "Title"
a header naming a column twice id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order,title → error: the header must have the columns id, title, notes, done, priority, due, tags, recurrenceFrequency, recurrenceInterval, recurrenceAnchor, createdAt, completedAt, order: column "title" appears twice
a row one field short id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 t1… → error: row 3: expected 13 fields, found 12
a row with a trailing comma has one field too many id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0, → error: row 2: expected 13 fields, found 14
a quoted field that is never closed id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,"Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 → error: row 2: a quoted field is not closed
text after a closing quote id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,"Buy" milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 → error: row 2: text after the closing quote of a field
a double quote inside a field that is not quoted id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Read "Dune",,false,none,,,,,,2026-09-28T09:30:00Z,,0 → error: row 2: a field with a double quote in it must be quoted
a lone CR outside quotes (old Mac line endings) id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 → error: row 1: a CR outside quotes must be followed by LF
done must be true or false, not yes id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,yes,none,,,,,,2026-09-28T09:30:00Z,,0 → error: row 2: done must be true or false, found "yes"
done is case-sensitive: TRUE is refused id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,TRUE,none,,,,,,2026-09-28T09:30:00Z,,0 → error: row 2: done must be true or false, found "TRUE"
an interval that is not a whole number id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,2026-10-01,,daily,1.5,2026-10-01… → error: row 2: recurrenceInterval must be a whole number, found "1.5"
an empty order id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,, → error: row 2: order must be a whole number, found ""
an order with a plus sign id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,+1 → error: row 2: order must be a whole number, found "+1"
an order in Arabic-Indic digits id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,٣ → error: row 2: order must be a whole number, found "٣"
an order with 16 digits is refused id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,12345… → error: row 2: order must be a whole number, found "1234567890123456"
recurrence columns partly filled id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,2026-10-01,,daily,,2026-10-01,20… → error: row 2: fill in all three recurrence columns or leave them all empty
a blank title id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1, ,,false,none,,,,,,2026-09-28T09:30:00Z,,0 → error: row 2: title: Enter a title.
spaces around the title id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1, Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 → error: row 2: title: Remove the spaces around the title.
an unknown priority and a bad due date: priority comes first id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,urgent,2026-02-30,,,,,2026-09-28T09:3… → error: row 2: priority: Choose a priority: none, low, medium or high.
a done todo without a completion time id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,true,none,,,,,,2026-09-28T09:30:00Z,,0 → error: row 2: completedAt: A done todo needs the time it was completed.
a negative order is a whole number, so validateTodo reports it id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,-1 → error: row 2: order: Order must be a whole number, 0 or more.
an interval of 0 id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,2026-10-01,,daily,0,2026-10-01,2… → error: row 2: recurrence: Repeat every 1 to 999 days, weeks, months or years.
eleven different tags id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,a b c d e f g h i j k,,,,2026-0… → error: row 2: tags: Use no more than 10 tags.
an empty id id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order ,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 → error: row 2: id: Every todo needs an id.
a timestamp with an offset id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00+01:00,,… → error: row 2: createdAt: Record when it was created as a UTC timestamp, e.g. 2026-09-28T09:30:00Z.
the same id twice id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 t1… → error: row 3: duplicate id "t1"
rows are records, not lines: empty lines and line breaks in quotes are not counted id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,"a b c",false,none,,,,,,2026-09-28T09:30:0… → error: row 3: done must be true or false, found "maybe"
the first bad row wins, even when a later row cannot be read id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,no,none,,,,,,2026-09-28T09:30:00Z,,0 t2,"o… → error: row 2: done must be true or false, found "no"

validateTodoCsv 13 tests

CaseArgumentsExpected
a good file is valid and counts its rows id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0 t2… → valid true, rows 2, errors
a header with no rows is a valid, empty file id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order → valid true, rows 0, errors
empty text has no header row → valid false, rows 0, errors ×1
every bad row is listed, and a good row between them is not id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,yes,none,,,,,,2026-09-28T09:00:00Z,,0 t2, … → valid false, rows 4, errors ×6
one row with three columns that cannot be read lists all three id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,maybe,none,,,weekly,,2026-09-28,2026-09-28T… → valid false, rows 1, errors ×3
an unreadable done does not also blame the completion time id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,yes,none,,,,,,2026-09-28T09:00:00Z,2026-09-… → valid false, rows 1, errors ×1
an unreadable interval is reported once, not again as a bad repeat id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Water plants,,false,none,2026-09-28,,daily,1.5,2026-0… → valid false, rows 1, errors ×1
a duplicate id is listed alongside the row's other problem id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0 t1… → valid false, rows 2, errors ×2
a row with the wrong number of fields does not stop the rows after it id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk t2,Call mum,,false,none,,,,,,2026-09-28T09:… → valid false, rows 3, errors ×2
an unclosed quote ends the check at that row, since the rows after it cannot be found id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0 t2… → valid false, rows 1, errors ×1
Show the other 3 tests
CaseArgumentsExpected
a header without every column is one problem on row 1 id,title t1,Buy milk → valid false, rows 0, errors ×1
two rows without an id each say so, and are not duplicates of each other id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order ,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0 ,Cal… → valid false, rows 2, errors ×2
rows count records, not lines: a byte order mark and blank lines are skipped id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0 … → valid false, rows 2, errors ×1

More from the author

CSV as in [RFC 4180, Common Format and MIME Type for Comma-Separated Values (CSV) Files](https://www.rfc-editor.org/rfc/rfc4180). The same format is written by todo.export-csv and read by todo.import-csv.

- A header row, then one row per todo. todo.export-csv writes these 13 columns in this order: `id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order` - `done` is `true` or `false`. `tags` are joined with single spaces. The repeat takes three columns, all empty when the todo does not repeat. `order` and `recurrenceInterval` are decimal integers. A null field (`notes`, `due`, `completedAt`) is empty. - A field is quoted with double quotes exactly when it contains a comma, a double quote, CR or LF, and a double quote inside it is doubled. Nothing else is quoted, and spaces are kept as they are. - Every row, the last one included, ends with CRLF.

**Caveat for spreadsheets.** Nothing is escaped against formula injection: a title such as `=1+1` or `=HYPERLINK(...)` is written as it is, because prefixing it with `'` would change the title when the file is read back. If your app offers the file for opening in Excel, LibreOffice or Google Sheets, warn people, or write a separate spreadsheet export that escapes cells starting with `=`, `+`, `-` or `@`.

## What it accepts beyond that

Files come back from spreadsheets and other tools, so reading is a little wider than writing:

- CRLF or LF line endings (CR or LF inside a quoted field is kept as it is), an optional UTF-8 byte order mark at the start, and an optional line break after the last row. Empty lines are skipped. - The 13 columns in any order, each exactly once and nothing else. Names are matched exactly (`Title` is not `title`). - Any field may be quoted, needed or not. - Tags are split on spaces and normalised with todo.normalise-tags, so `#Home Work,` imports as `home work`. - `order` and `recurrenceInterval` accept leading zeros and a leading `-` (so a negative order is reported by `validateTodo`'s own message).

Empty `notes`, `due` and `completedAt` fields become null.

## Errors

Rows are records, counted from 1 for the header, so the first todo is row 2. A quoted field with line breaks is one row, and skipped empty lines are not counted. Rows are read in order and the first problem is thrown:

- `the CSV has no header row` (empty text, or only empty lines) - `the header must have the columns id, title, notes, done, priority, due, tags, recurrenceFrequency, recurrenceInterval, recurrenceAnchor, createdAt, completedAt, order: ` then `unknown column "colour"`, `column "title" appears twice` or `missing column "order"` - `row N: a quoted field is not closed` - `row N: text after the closing quote of a field` - `row N: a field with a double quote in it must be quoted` - `row N: a CR outside quotes must be followed by LF` (old Mac line endings) - `row N: expected 13 fields, found 12` - `row N: done must be true or false, found "yes"` (case-sensitive) - `row N: fill in all three recurrence columns or leave them all empty` - `row N: recurrenceInterval must be a whole number, found "1.5"` and the same for `order`: ASCII digits, an optional leading `-`, at most 15 digits (no `+`, spaces, decimals or exponents) - `row N: <field>: <message>`, the first of `validateTodo`'s messages in field order, e.g. `row 2: title: Remove the spaces around the title.` - `row N: duplicate id "t1"`

## Why it is shaped this way

All or nothing: a half-imported list is harder to fix than a file, so one bad row refuses the whole file and says where to look. The row number counts records rather than lines because that is what a spreadsheet shows, and a quoted note with line breaks is one spreadsheet row.

Unknown columns are refused rather than ignored, so a misspelt header (`compltedAt`) is caught instead of silently dropping data.

## New in 1.1.0: every problem at once

`validateTodoCsv(csv)` reads the same file and returns every problem instead of throwing the first, so an import screen can list them all and the person can fix the file in one go:

{"valid": false, "rows": 4, "errors": [
  {"row": 2, "field": "done", "message": "done must be true or false, found \"yes\""},
  {"row": 3, "field": "title", "message": "Enter a title."},
  {"row": 5, "field": "priority", "message": "Choose a priority: none, low, medium or high."},
  {"row": 5, "field": "due", "message": "Enter the due date as a real date, YYYY-MM-DD."}
]}

- `row` counts records with the header as row 1, as importCsv does. `rows` is how many todo rows were read (the header not counted). - `field` is the todo field `validateTodo` names (`title`, `due`, `completedAt`...) or the CSV column that could not be read (`done`, `recurrenceInterval`, `order`; `recurrence` when only some of the three repeat columns are filled; `id` for a duplicate). It is null when the row as a whole is wrong (the wrong number of fields, a quoting problem) and for header problems. - `message` is importCsv's message without the `row N:` prefix, and without the `field:` prefix for `validateTodo`'s messages. - Within a row, problems come in the order importCsv checks them: the field count, `done`, the repeat columns, `order`, then `validateTodo`'s messages in field order, then a duplicate id. So the first problem in the list is exactly what importCsv throws, and the file is valid exactly when importCsv accepts it. - **No knock-on messages.** A column that cannot be read is reported once: an unreadable `done` does not also blame `completedAt`, and an unreadable interval does not also give the "repeat every 1 to 999" message. - A duplicate id is reported even when the row has other problems, and every row's id counts, so fixing the first problem does not reveal a new one. Rows without an id say so and are not duplicates of each other. - **What stops the check:** a header that is not exactly the 13 columns (one problem, row 1), no header at all, and a record that cannot be split into fields (an unclosed quote, text after a closing quote, a bare quote or CR). After that the rows cannot be found reliably, so the list ends there.

`importCsv` is unchanged: it now reads the file through the same code and throws the first problem, word for word as 1.0.0 did, and every 1.0.0 vector is kept. 1.0.0 was one file; 1.1.0 is a group of two (see `spec/AUTHORING.md`, Groups), so `import { importCsv } from "#fune/todo.import-csv@^1"` keeps working.

Files

PathBytes
README.md6,846
impl/python/import_csv.py407
impl/python/validate_todo_csv.py7,472
impl/rust/import_csv.rs768
impl/rust/validate_todo_csv.rs10,649
impl/typescript/import_csv.ts639
impl/typescript/validate_todo_csv.ts7,729
vectors.json25,621