todo.merge
Merge imported todos into a list by id: an imported todo replaces the one with its id, new ids are appended.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 11 tests, run in TypeScript, Python and Rust.
What it does
Merges todos brought in from elsewhere (an imported CSV file, another device) into a list, by id, instead of replacing the whole list.
| id is in | result | | --- | --- | | both lists | the **incoming** todo, in the place the current one had | | only `current` | kept as it is | | only `incoming` | appended after the current todos |
For example
merge_todos(, )→ two empty lists merge to an empty listmerge_todos(, incoming ×2)→ ×2 into an empty list, the incoming todos arrive in their own manual ordermerge_todos(current ×2, )→ ×2 nothing incoming: the current list in manual order, renumbered
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 merge_todos(current: &[Todo], incoming: &[Todo]) -> Vec<Todo>
| current | Todo[] | the list as it is now |
| incoming | Todo[] | the todos being brought in, e.g. from todo.import-csv; they win for ids in both |
| returns | Todo[] | in manual order with order renumbered 0 to n-1: the current todos where they were, then the new ones |
Your code names it in one line, in the file that uses it
fune!(todo.merge@^1); // then call merge_todos(…)
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::Todo; ← from todo.item ^1.0.0 · built alongside by fune
use super::todo_item_validate_todo::{todos_from_value, todos_to_value};
/// Manual order: order ascending, ties by position (sort_by_key is stable).
fn in_manual_order(todos: &[Todo], which: &str) -> Vec<Todo> {
let mut seen: HashSet<&str> = HashSet::new();
for todo in todos {
if !seen.insert(todo.id.as_str()) {
panic!("duplicate id \"{}\" in the {} list", todo.id, which);
}
}
let mut sorted = todos.to_vec();
sorted.sort_by_key(|todo| todo.order);
sorted
}
/// Merge by id: a todo in both lists is taken from `incoming`, in the place
/// the current one had; todos only in `incoming` follow in their own manual
/// order. The result is in manual order, renumbered 0 to n-1.
///
/// # Panics
/// On an id that appears twice in either list.
pub fn merge_todos(current: &[Todo], incoming: &[Todo]) -> Vec<Todo> {
let mine = in_manual_order(current, "current");
let theirs = in_manual_order(incoming, "incoming");
let kept: HashSet<&str> = mine.iter().map(|todo| todo.id.as_str()).collect();
let mut merged: Vec<Todo> = mine
.iter()
.map(|todo| theirs.iter().find(|t| t.id == todo.id).unwrap_or(todo).clone())
.collect();
merged.extend(theirs.iter().filter(|todo| !kept.contains(todo.id.as_str())).cloned());
for (order, todo) in merged.iter_mut().enumerate() {
todo.order = order as i64;
}
merged
}
pub fn fune_vector(args: &[Value]) -> Value {
todos_to_value(&merge_todos(&todos_from_value(&args[0]), &todos_from_value(&args[1])))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 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.merge
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./todo.merge-1.0.0-rust.fune, or fetch it from a terminal with fune pull todo.merge@1.0.0:rust.
The whole function, every language, is one file too: todo.merge-1.0.0.fune, 19,135 bytes, sha256 70fd49a1e279c942df6f45cf86dbc03153376c38c62cd1836180141e1f1f11db. 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.merge
after — your function gets the result and the arguments, and returns the final result.
// fune: after todo.merge
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.merge
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.merge --steps.
// fune: step todo.merge after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| two empty lists merge to an empty list | , | → | |
| into an empty list, the incoming todos arrive in their own manual order | , incoming ×2 | → | ×2 |
| nothing incoming: the current list in manual order, renumbered | current ×2, | → | ×2 |
| an id in both: the incoming todo wins, in the current one's place | current ×2, incoming ×1 | → | ×2 |
| new ids are appended after the current todos | current ×2, incoming ×2 | → | ×3 |
| equal orders keep their array order | current ×2, | → | ×2 |
| a new todo with order 0 still goes after the current ones, and gaps close | current ×2, incoming ×1 | → | ×3 |
| a done todo reopened in the file comes back open | current ×1, incoming ×1 | → | ×1 |
| a mixed merge: kept, replaced and new | current ×3, incoming ×2 | → | ×4 |
| an id twice in the incoming list is an error | , incoming ×2 | → | error: duplicate id "a" in the incoming list |
Show the other 1 test
| Case | Arguments | Expected | |
|---|---|---|---|
| an id twice in the current list is an error | current ×2, | → | error: duplicate id "a" in the current list |
More from the author
## Which copy wins
The incoming one. A todo (todo.item) records when it was created and completed but not when it was last edited, so there is no honest way to tell which of two copies is newer. Importing a file is the person saying "these are the versions I want", so the file's row is treated as the newer copy: every field comes from it, done state and timestamps included.
## Order
The result is in manual order (todo.sort's `manual`: `order` ascending, ties by array position), renumbered 0 to n-1, as todo.list's `moveTodo` leaves a list:
1. The current todos in their manual order, each replaced by its incoming copy where there is one. A replaced todo keeps its **current** position: the person arranged this list, and the file's `order` is a position in a different list. 2. Then the todos only in `incoming`, in the incoming list's own manual order, so a file's order among its new rows is kept.
So a new todo whose file `order` is 0 still goes after the existing ones, and gaps in the current orders close up.
## Errors
An id that appears twice in either list is an error (`duplicate id "a" in the incoming list`), because it is ambiguous which copy to keep. The todos are not otherwise checked: todo.import-csv has already checked a file's rows with todo.item's `validateTodo`.
Files
| Path | Bytes |
|---|---|
| README.md | 1,659 |
| impl/python.py | 1,049 |
| impl/rust.rs | 1,646 |
| impl/typescript.ts | 1,301 |
| vectors.json | 10,258 |