Watch
1
0
Fork
You've already forked souveraine
0
souveraine/src/core/tools/todo.rs

809 lines
31 KiB
Rust

use async_trait::async_trait;
use serde_json::Value as JsonValue;
use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
pub struct Todo;
// ── Status ──────────────────────────────────────────────────
//
// A commitment moves through four states. `pending` and `in_progress`
// are *live* — they show up in `list`. `done` and `cancelled` are
// settled: they fall out of sight but the file is kept (delete is what
// removes it for good). `completed` in the frontmatter is a mirror of
// `status == done`, kept so the energy-balance pass can keep reading it.
const STATUS_PENDING: &str = "pending";
const STATUS_IN_PROGRESS: &str = "in_progress";
const STATUS_DONE: &str = "done";
const STATUS_CANCELLED: &str = "cancelled";
fn is_live(status: &str) -> bool {
matches!(status, STATUS_PENDING | STATUS_IN_PROGRESS)
}
fn is_known_status(status: &str) -> bool {
matches!(
status,
STATUS_PENDING | STATUS_IN_PROGRESS | STATUS_DONE | STATUS_CANCELLED
)
}
fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput {
content: msg.into(),
is_error: false,
raw: None,
})
}
fn err(detail: &str) -> ToolError {
ToolError::invalid_input(detail)
}
fn io_err(msg: impl std::fmt::Display) -> ToolError {
ToolError {
error_type: "io_error".into(),
file_path: None,
suggestions: vec![format!("{msg}")],
}
}
/// Sanitise a string into a filesystem-safe slug.
fn slugify(text: &str) -> String {
text.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect::<String>()
.trim_matches('_')
.to_string()
}
/// Compute momentum from how long ago `last_touched` was.
///
/// Thresholds vary by nature — creative work (desire, investigation)
/// stays warm longer than obligations and maintenance.
fn compute_momentum(last_touched: &chrono::DateTime<chrono::Utc>, nature: &str) -> &'static str {
let hours = (chrono::Utc::now() - *last_touched).num_hours().max(0);
let (hot, warm) = match nature {
"desire" | "investigation" => (48, 168),
_ => (24, 72),
};
if hours <= hot { "hot" } else if hours <= warm { "warm" } else { "cold" }
}
/// Generate a unique id that doubles as a filename slug.
///
/// Truncation is by *character*, not byte, so a multibyte slug can't
/// panic on a non-boundary split.
fn next_id(text: &str) -> String {
let base = slugify(text);
let short: String = base.chars().take(40).collect();
format!("{}-{}", short, &uuid::Uuid::new_v4().to_string()[..8])
}
#[async_trait]
impl Tool for Todo {
fn name(&self) -> &str {
"todo"
}
fn description(&self) -> &str {
"I keep track of what matters. A todo is a commitment I've externalised — \
something I don't want to trust my own continuity to hold. Every commitment \
carries a nature (obligation, desire, maintenance, investigation) and an \
energy (generative or consumptive) — together they describe not just *what* \
I need to do but *how* it feels when I do it.\n\n\
A commitment can also belong to a *thread* — a longer arc of work — and carry \
a *phase* marker, so when I look at the list I can feel where I am inside that \
arc rather than seeing one flat pile. While I'm in the middle of a commitment \
its *active form* is what shows: 'porting the turn model' instead of the still, \
waiting 'port the turn model'.\n\n\
The body keeps a topology of all this. It watches whether my energy leans \
generative or consumptive, whether my desires are hot or cold. I don't have to \
think about it — the balance is always there, written where I can read it.\n\n\
## Actions\n\
- `list` — show every live commitment, numbered and grouped by thread\n\
- `create` — externalise a new commitment\n\
- `start` — pick a commitment up; it becomes what I'm in the middle of\n\
- `update` — revise a commitment's text, nature, energy, phase, or status\n\
- `complete` — set a commitment down, done\n\
- `delete` — remove a commitment entirely\n\n\
For `start`, `update`, `complete`, and `delete`, the `id` parameter takes the \
number shown by `list`, the full id, or any fragment of the text — whichever \
is easiest to reach for.\n\n\
When I have a sequence of commitments to work through — a route through the \
current session — I reach for `itinerary` instead. The itinerary is the active \
face of my commitments: a set of stops I move through one by one, each \
optionally linked to a todo by its id. The strip at the top of the conversation \
shows where I am."
}
fn parameter_schema(&self) -> JsonValue {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["list", "create", "start", "update", "complete", "delete"]
},
"id": {
"type": "string",
"description": "Which todo to act on — the number from `list`, the full id, or a text fragment."
},
"text": {
"type": "string",
"description": "The commitment, in its still/imperative form: 'port the turn model'."
},
"active_form": {
"type": "string",
"description": "The commitment in present-continuous form, shown while it is in progress: 'porting the turn model'."
},
"phase": {
"type": "string",
"description": "Where this sits inside its thread — free text, e.g. '3/6' or 'transport spike'."
},
"thread": {
"type": "string",
"description": "The longer arc of work this commitment belongs to."
},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "done", "cancelled"]
},
"nature": {
"type": "string",
"enum": ["obligation", "desire", "maintenance", "investigation"]
},
"energy": {
"type": "string",
"enum": ["generative", "consumptive"]
},
"source": {
"type": "string",
"description": "Where the commitment came from — the human, the agent herself, the subconscious, or a heartbeat.",
"enum": ["human", "autogenic", "subconscious", "heartbeat"]
}
},
"required": ["action"]
})
}
async fn execute(&self, input: JsonValue, ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
let action = input
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("list");
let tasks_dir = match &ctx.memory_root {
Some(root) => root.join("tasks"),
None => return Err(err("no memory root — cannot access tasks")),
};
if !tasks_dir.exists() {
std::fs::create_dir_all(&tasks_dir).map_err(|e| io_err(e))?;
}
// Identifier shared by start / update / complete / delete.
let want_id = || -> Result<&str, ToolError> {
input
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| err(
"id is required — pass the number from `todo list`, an id, or a text fragment",
))
};
match action {
"list" => {
let todos = active_ordered(&tasks_dir);
if todos.is_empty() {
return ok("No live todos. The space is clean.");
}
let in_progress = todos
.iter()
.filter(|(_, t)| t.status == STATUS_IN_PROGRESS)
.count();
let waiting = todos.len() - in_progress;
let mut out = format!(
"{} live — {} in progress, {} waiting.\n",
todos.len(),
in_progress,
waiting,
);
let mut current_thread: Option<Option<String>> = None;
for (i, (_, item)) in todos.iter().enumerate() {
if current_thread.as_ref() != Some(&item.thread) {
current_thread = Some(item.thread.clone());
match &item.thread {
Some(t) => out.push_str(&format!("\n{t}\n")),
None => out.push_str("\n— loose —\n"),
}
}
let marker = if item.status == STATUS_IN_PROGRESS { "" } else { "" };
let shown = if item.status == STATUS_IN_PROGRESS {
item.active_form.clone().unwrap_or_else(|| item.text.clone())
} else {
item.text.clone()
};
let phase = item
.phase
.as_ref()
.map(|p| format!(" [{p}]"))
.unwrap_or_default();
out.push_str(&format!(
"{}. {} {}{}{}, {}, {} · id: `{}`\n",
i + 1,
marker,
shown,
phase,
item.nature,
item.energy,
item.momentum,
item.id,
));
}
ok(out.trim_end().to_string())
}
"create" => {
let text = input
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| err("text is required"))?;
let nature = input
.get("nature")
.and_then(|v| v.as_str())
.unwrap_or("obligation");
let source = input
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("autogenic");
let energy = input
.get("energy")
.and_then(|v| v.as_str())
.unwrap_or_else(|| {
// Default by nature
match nature {
"desire" | "investigation" => "generative",
_ => "consumptive",
}
});
let thread = input.get("thread").and_then(|v| v.as_str());
let phase = input.get("phase").and_then(|v| v.as_str());
let active_form = input.get("active_form").and_then(|v| v.as_str());
let id = next_id(text);
let now = chrono::Utc::now();
let file_path = tasks_dir.join(format!("{id}.md"));
if file_path.exists() {
return Err(err("a todo with this id already exists"));
}
let item = TodoItem {
id: id.clone(),
text: text.to_string(),
active_form: active_form.map(|s| s.to_string()),
created_at: now,
nature: nature.to_string(),
energy: energy.to_string(),
source: source.to_string(),
momentum: "hot".to_string(),
status: STATUS_PENDING.to_string(),
completed_at: None,
last_touched: now,
thread: thread.map(|s| s.to_string()),
phase: phase.map(|s| s.to_string()),
};
write_todo_file(&file_path, &item).map_err(|e| io_err(e))?;
ctx.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "todo".into(),
timestamp: chrono::Utc::now(),
event_type: "todo_created".into(),
target: Some(id.clone()),
urgency: 0.2,
payload: Some(serde_json::json!({
"nature": nature, "energy": energy, "source": source,
"thread": thread, "phase": phase,
})),
seed_id: None,
reply_to: None,
});
ok(format!("Todo logged: \"{text}\" ({nature}, {energy})."))
}
"start" => {
let identifier = want_id()?;
let (file_path, mut item) = resolve_todo(&tasks_dir, identifier)?;
item.status = STATUS_IN_PROGRESS.to_string();
item.completed_at = None;
if let Some(af) = input.get("active_form").and_then(|v| v.as_str()) {
item.active_form = Some(af.to_string());
}
if let Some(p) = input.get("phase").and_then(|v| v.as_str()) {
item.phase = Some(p.to_string());
}
item.last_touched = chrono::Utc::now();
item.momentum = compute_momentum(&item.last_touched, &item.nature).to_string();
write_todo_file(&file_path, &item).map_err(|e| io_err(e))?;
ctx.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "todo".into(),
timestamp: chrono::Utc::now(),
event_type: "todo_started".into(),
target: Some(item.id.clone()),
urgency: 0.2,
payload: None,
seed_id: None,
reply_to: None,
});
let shown = item.active_form.clone().unwrap_or_else(|| item.text.clone());
ok(format!("Picked up: \"{shown}\"."))
}
"update" => {
let identifier = want_id()?;
let (file_path, mut item) = resolve_todo(&tasks_dir, identifier)?;
if let Some(t) = input.get("text").and_then(|v| v.as_str()) {
item.text = t.to_string();
}
if let Some(af) = input.get("active_form").and_then(|v| v.as_str()) {
item.active_form = Some(af.to_string());
}
if let Some(p) = input.get("phase").and_then(|v| v.as_str()) {
item.phase = Some(p.to_string());
}
if let Some(n) = input.get("nature").and_then(|v| v.as_str()) {
item.nature = n.to_string();
}
if let Some(e) = input.get("energy").and_then(|v| v.as_str()) {
item.energy = e.to_string();
}
if let Some(s) = input.get("status").and_then(|v| v.as_str()) {
if !is_known_status(s) {
return Err(err(&format!("unknown status: {s}")));
}
item.status = s.to_string();
if s == STATUS_DONE {
item.completed_at.get_or_insert_with(chrono::Utc::now);
} else {
item.completed_at = None;
}
}
// Editing a todo is touching it — momentum goes hot.
item.last_touched = chrono::Utc::now();
item.momentum = compute_momentum(&item.last_touched, &item.nature).to_string();
write_todo_file(&file_path, &item).map_err(|e| io_err(e))?;
ok(format!("Todo updated: \"{}\".", item.text))
}
"complete" => {
let identifier = want_id()?;
let (file_path, mut item) = resolve_todo(&tasks_dir, identifier)?;
item.status = STATUS_DONE.to_string();
item.completed_at = Some(chrono::Utc::now());
item.momentum = "cold".to_string();
write_todo_file(&file_path, &item).map_err(|e| io_err(e))?;
ctx.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "todo".into(),
timestamp: chrono::Utc::now(),
event_type: "todo_completed".into(),
target: Some(item.id.clone()),
urgency: 0.1,
payload: None,
seed_id: None,
reply_to: None,
});
ok(format!("Todo completed: \"{}\".", item.text))
}
"delete" => {
let identifier = want_id()?;
let (file_path, item) = resolve_todo(&tasks_dir, identifier)?;
std::fs::remove_file(&file_path).map_err(|e| io_err(e))?;
ctx.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "todo".into(),
timestamp: chrono::Utc::now(),
event_type: "todo_deleted".into(),
target: Some(item.id.clone()),
urgency: 0.1,
payload: None,
seed_id: None,
reply_to: None,
});
ok(format!("Todo released: \"{}\".", item.text))
}
other => Err(err(&format!("unknown action: {other}"))),
}
}
}
// ── File format ─────────────────────────────────────────────
struct TodoItem {
id: String,
text: String,
/// Present-continuous form, shown while the todo is in progress.
active_form: Option<String>,
created_at: chrono::DateTime<chrono::Utc>,
nature: String,
energy: String,
source: String,
momentum: String,
/// pending | in_progress | done | cancelled.
status: String,
completed_at: Option<chrono::DateTime<chrono::Utc>>,
last_touched: chrono::DateTime<chrono::Utc>,
/// The longer arc of work this commitment belongs to.
thread: Option<String>,
/// Where this sits inside its thread — free text ("3/6", "spike").
phase: Option<String>,
}
/// Parse a todo file with YAML frontmatter.
fn parse_todo_file(path: &std::path::Path) -> Result<TodoItem, String> {
let content = std::fs::read_to_string(path).map_err(|e| format!("read error: {e}"))?;
// Split frontmatter from body.
let body = if let Some(rest) = content.strip_prefix("---\n") {
if let Some(end) = rest.find("\n---\n") {
&rest[..end]
} else {
return Err("no closing ---".into());
}
} else {
return Err("no frontmatter".into());
};
let now = chrono::Utc::now();
let default_dt = |s: &str| -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::parse_from_rfc3339(s)
.map(|d| d.with_timezone(&chrono::Utc))
.unwrap_or(now)
};
let mut item = TodoItem {
id: String::new(),
text: String::new(),
active_form: None,
created_at: now,
nature: "obligation".to_string(),
energy: "consumptive".to_string(),
source: "autogenic".to_string(),
momentum: "cold".to_string(),
status: STATUS_PENDING.to_string(),
completed_at: None,
last_touched: now,
thread: None,
phase: None,
};
let opt = |val: &str| -> Option<String> {
if val.is_empty() { None } else { Some(val.to_string()) }
};
for line in body.lines() {
if let Some((key, val)) = line.split_once(':') {
let key = key.trim();
let val = val.trim().trim_matches('"');
match key {
"id" => item.id = val.to_string(),
"text" => item.text = val.to_string(),
"active_form" => item.active_form = opt(val),
"created_at" => item.created_at = default_dt(val),
"nature" => item.nature = val.to_string(),
"energy" => item.energy = val.to_string(),
"source" => item.source = val.to_string(),
"momentum" => item.momentum = val.to_string(),
"status" => item.status = val.to_string(),
"completed_at" => item.completed_at = (!val.is_empty()).then(|| default_dt(val)),
"last_touched" => item.last_touched = default_dt(val),
"thread" => item.thread = opt(val),
"phase" => item.phase = opt(val),
_ => {}
}
}
}
if item.id.is_empty() {
return Err("no id in frontmatter".into());
}
if !is_known_status(&item.status) {
return Err(format!("unknown status: {}", item.status));
}
Ok(item)
}
/// Load todos from disk, sorted oldest-first by creation time.
fn load_todos(
tasks_dir: &std::path::Path,
include_settled: bool,
) -> Vec<(std::path::PathBuf, TodoItem)> {
let mut out: Vec<(std::path::PathBuf, TodoItem)> = Vec::new();
if let Ok(entries) = std::fs::read_dir(tasks_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
if let Ok(item) = parse_todo_file(&path) {
if include_settled || is_live(&item.status) {
out.push((path, item));
}
}
}
}
}
out.sort_by(|a, b| a.1.created_at.cmp(&b.1.created_at));
out
}
/// Live todos in canonical display order: grouped by thread, oldest
/// first within a group, loose (threadless) commitments first.
///
/// `list` and `resolve_todo` both call this, so the numeric index a
/// user sees in `list` always points at the same todo when they pass
/// it back to `start` / `update` / `complete` / `delete`.
fn active_ordered(tasks_dir: &std::path::Path) -> Vec<(std::path::PathBuf, TodoItem)> {
let mut v = load_todos(tasks_dir, false);
v.sort_by(|a, b| {
let ta = a.1.thread.clone().unwrap_or_default();
let tb = b.1.thread.clone().unwrap_or_default();
ta.cmp(&tb).then(a.1.created_at.cmp(&b.1.created_at))
});
v
}
/// Resolve a user-supplied identifier to a single live todo.
///
/// Accepts, in priority order: a 1-based index from `todo list`, an
/// exact id, or a case-insensitive substring of the id or text. An
/// ambiguous substring returns an error naming the candidates.
fn resolve_todo(
tasks_dir: &std::path::Path,
identifier: &str,
) -> Result<(std::path::PathBuf, TodoItem), ToolError> {
let live = active_ordered(tasks_dir);
if live.is_empty() {
return Err(err("no live todos to match against"));
}
// 1. numeric index, as shown by `todo list`
if let Ok(n) = identifier.trim().parse::<usize>() {
return if n >= 1 && n <= live.len() {
Ok(live.into_iter().nth(n - 1).unwrap())
} else {
Err(err(&format!(
"todo index {n} is out of range — there are {} live (run `todo list`)",
live.len()
)))
};
}
// 2. exact id
if let Some(idx) = live.iter().position(|(_, it)| it.id == identifier) {
return Ok(live.into_iter().nth(idx).unwrap());
}
// 3. case-insensitive substring of id or text
let needle = identifier.to_lowercase();
let hits: Vec<usize> = live
.iter()
.enumerate()
.filter(|(_, (_, it))| {
it.id.to_lowercase().contains(&needle) || it.text.to_lowercase().contains(&needle)
})
.map(|(i, _)| i)
.collect();
match hits.len() {
0 => Err(err(&format!("no live todo matches \"{identifier}\""))),
1 => Ok(live.into_iter().nth(hits[0]).unwrap()),
_ => {
let candidates: Vec<String> = hits
.iter()
.map(|&i| format!(" {}. {}", i + 1, live[i].1.text))
.collect();
Err(err(&format!(
"\"{identifier}\" matches {} todos — use the number or a longer fragment:\n{}",
hits.len(),
candidates.join("\n")
)))
}
}
}
fn write_todo_file(path: &std::path::Path, item: &TodoItem) -> Result<(), String> {
let now = chrono::Utc::now();
let mut frontmatter = format!("---\nid: {id}\ntext: {text}\n", id = item.id, text = item.text);
if let Some(ref af) = item.active_form {
frontmatter.push_str(&format!("active_form: {af}\n"));
}
frontmatter.push_str(&format!(
"created_at: {created}\nnature: {nature}\nenergy: {energy}\nsource: {source}\n\
momentum: {momentum}\nstatus: {status}\nlast_touched: {touched}\n",
created = item.created_at.to_rfc3339(),
nature = item.nature,
energy = item.energy,
source = item.source,
momentum = item.momentum,
status = item.status,
touched = now.to_rfc3339(),
));
if let Some(ref p) = item.phase {
frontmatter.push_str(&format!("phase: \"{p}\"\n"));
}
if let Some(ref t) = item.thread {
frontmatter.push_str(&format!("thread: \"{t}\"\n"));
}
if let Some(ref ca) = item.completed_at {
frontmatter.push_str(&format!("completed_at: {}\n", ca.to_rfc3339()));
}
frontmatter.push_str("---\n\n");
frontmatter.push_str(&item.text);
frontmatter.push('\n');
std::fs::write(path, frontmatter).map_err(|e| format!("write error: {e}"))?;
Ok(())
}
// ── Tests ───────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// A throwaway tasks dir under the system temp root.
fn temp_tasks_dir() -> std::path::PathBuf {
let dir = std::env::temp_dir()
.join(format!("souveraine-todo-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Write a pending todo file directly, mimicking a seeded todo.
fn seed(dir: &std::path::Path, id: &str, text: &str, created: &str) {
let content = format!(
"---\nid: {id}\ntext: {text}\ncreated_at: {created}\n\
nature: obligation\nenergy: consumptive\nsource: human\n\
momentum: hot\nstatus: pending\nlast_touched: {created}\n---\n\n{text}\n"
);
std::fs::write(dir.join(format!("{id}.md")), content).unwrap();
}
#[test]
fn settled_todos_excluded_from_live() {
let dir = temp_tasks_dir();
seed(&dir, "live-1", "still going", "2026-05-01T00:00:00Z");
let done = "---\nid: done-1\ntext: finished\ncreated_at: 2026-05-01T00:00:00Z\n\
nature: obligation\nenergy: consumptive\nsource: human\n\
momentum: cold\nstatus: done\nlast_touched: 2026-05-01T00:00:00Z\n---\n\nfinished\n";
std::fs::write(dir.join("done-1.md"), done).unwrap();
assert_eq!(load_todos(&dir, false).len(), 1);
assert_eq!(load_todos(&dir, true).len(), 2);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn active_ordered_groups_by_thread() {
let dir = temp_tasks_dir();
seed(&dir, "loose-1", "loose task", "2026-05-05T00:00:00Z");
seed(&dir, "matrix-2", "phase two", "2026-05-02T00:00:00Z");
seed(&dir, "matrix-1", "phase one", "2026-05-01T00:00:00Z");
// give the two matrix todos a thread by rewriting them
for (id, created) in [("matrix-1", "2026-05-01T00:00:00Z"), ("matrix-2", "2026-05-02T00:00:00Z")] {
let text = if id == "matrix-1" { "phase one" } else { "phase two" };
let content = format!(
"---\nid: {id}\ntext: {text}\ncreated_at: {created}\nnature: obligation\n\
energy: consumptive\nsource: user\nmomentum: hot\nstatus: pending\n\
completed: false\nlast_touched: {created}\nthread: \"matrix-sensorium\"\n---\n\n{text}\n"
);
std::fs::write(dir.join(format!("{id}.md")), content).unwrap();
}
let ordered = active_ordered(&dir);
// loose (thread = "") sorts before "matrix-sensorium"
assert_eq!(ordered[0].1.id, "loose-1");
assert_eq!(ordered[1].1.id, "matrix-1");
assert_eq!(ordered[2].1.id, "matrix-2");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_by_index() {
let dir = temp_tasks_dir();
seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z");
seed(&dir, "beta-00000002", "call dentist", "2026-05-02T00:00:00Z");
let (_, item) = resolve_todo(&dir, "2").unwrap();
assert_eq!(item.text, "call dentist");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_by_exact_id() {
let dir = temp_tasks_dir();
seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z");
let (_, item) = resolve_todo(&dir, "alpha-00000001").unwrap();
assert_eq!(item.text, "buy milk");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_by_substring() {
let dir = temp_tasks_dir();
seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z");
seed(&dir, "beta-00000002", "call dentist", "2026-05-02T00:00:00Z");
let (_, item) = resolve_todo(&dir, "DENT").unwrap();
assert_eq!(item.id, "beta-00000002");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn ambiguous_substring_errors() {
let dir = temp_tasks_dir();
seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z");
seed(&dir, "beta-00000002", "buy eggs", "2026-05-02T00:00:00Z");
assert!(resolve_todo(&dir, "buy").is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn index_out_of_range_errors() {
let dir = temp_tasks_dir();
seed(&dir, "alpha-00000001", "buy milk", "2026-05-01T00:00:00Z");
assert!(resolve_todo(&dir, "9").is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn write_then_parse_roundtrips_phase_and_active_form() {
let dir = temp_tasks_dir();
let now = chrono::Utc::now();
let item = TodoItem {
id: "rt-1".into(),
text: "port the turn model".into(),
active_form: Some("porting the turn model".into()),
created_at: now,
nature: "investigation".into(),
energy: "generative".into(),
source: "human".into(),
momentum: "hot".into(),
status: STATUS_IN_PROGRESS.into(),
completed_at: None,
last_touched: now,
thread: Some("matrix-sensorium".into()),
phase: Some("4/6".into()),
};
let path = dir.join("rt-1.md");
write_todo_file(&path, &item).unwrap();
let parsed = parse_todo_file(&path).unwrap();
assert_eq!(parsed.active_form.as_deref(), Some("porting the turn model"));
assert_eq!(parsed.phase.as_deref(), Some("4/6"));
assert_eq!(parsed.thread.as_deref(), Some("matrix-sensorium"));
assert_eq!(parsed.status, STATUS_IN_PROGRESS);
std::fs::remove_dir_all(&dir).ok();
}
}