use crate::db::Database;
use crate::persistence::Persistence as _;
use crate::providers::ToolDefinition;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TodoStatus {
Pending,
InProgress,
Completed,
}
impl TodoStatus {
fn from_str(s: &str) -> Option<Self> {
match s {
"pending" => Some(Self::Pending),
"in_progress" => Some(Self::InProgress),
"completed" => Some(Self::Completed),
_ => None,
}
}
fn checkbox(&self) -> &'static str {
match self {
Self::Pending => "[ ]",
Self::InProgress => "[→]",
Self::Completed => "[x]",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TodoPriority {
High,
Medium,
Low,
}
impl TodoPriority {
fn from_str(s: &str) -> Option<Self> {
match s {
"high" => Some(Self::High),
"medium" => Some(Self::Medium),
"low" => Some(Self::Low),
_ => None,
}
}
fn suffix(&self) -> &'static str {
match self {
Self::High => " ⚡",
Self::Medium | Self::Low => "",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TodoItem {
pub content: String,
pub status: TodoStatus,
pub priority: TodoPriority,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TodoChange {
pub before: TodoItem,
pub after: TodoItem,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct TodoDiff {
pub added: Vec<TodoItem>,
pub removed: Vec<TodoItem>,
pub changed: Vec<TodoChange>,
}
impl TodoDiff {
pub fn is_empty(&self) -> bool {
self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
}
fn compute(old: &[TodoItem], new: &[TodoItem]) -> Self {
let mut added = Vec::new();
let mut removed = Vec::new();
let mut changed = Vec::new();
for n in new {
match old.iter().find(|o| o.content == n.content) {
None => added.push(n.clone()),
Some(o) if o != n => changed.push(TodoChange {
before: o.clone(),
after: n.clone(),
}),
Some(_) => { }
}
}
for o in old {
if !new.iter().any(|n| n.content == o.content) {
removed.push(o.clone());
}
}
Self {
added,
removed,
changed,
}
}
}
#[derive(Debug, Clone)]
pub struct TodoWriteOutcome {
pub message: String,
pub items: Vec<TodoItem>,
pub diff: TodoDiff,
}
pub fn definitions() -> Vec<ToolDefinition> {
vec![ToolDefinition {
name: "TodoWrite".to_string(),
description: "Create and manage a structured task list for the current session. \
Rewrite the full list on every call — include all tasks, not just changed ones. \
Use proactively for: multi-step tasks (3+ steps), complex refactors, or when \
the user provides a list of things to do. Mark tasks `in_progress` BEFORE \
starting and `completed` immediately after finishing. Only one task should be \
`in_progress` at a time."
.to_string(),
parameters: json!({
"type": "object",
"properties": {
"todos": {
"type": "array",
"description": "The complete todo list (replaces any previous list)",
"items": {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "Actionable task description in imperative form"
},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed"],
"description": "Current status of the task"
},
"priority": {
"type": "string",
"enum": ["high", "medium", "low"],
"description": "Task priority"
}
},
"required": ["content", "status", "priority"]
}
}
},
"required": ["todos"]
}),
}]
}
pub async fn todo_write(db: &Database, session_id: &str, args: &Value) -> Result<TodoWriteOutcome> {
let raw = args
.get("todos")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("Missing 'todos' array"))?;
let mut todos: Vec<TodoItem> = Vec::with_capacity(raw.len());
for (i, item) in raw.iter().enumerate() {
let content = item
.get("content")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("todos[{i}]: 'content' must be a non-empty string"))?
.to_string();
let status_str = item
.get("status")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("todos[{i}]: missing 'status'"))?;
let status = TodoStatus::from_str(status_str).ok_or_else(|| {
anyhow::anyhow!(
"todos[{i}]: invalid status '{status_str}' — use pending/in_progress/completed"
)
})?;
let priority_str = item
.get("priority")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("todos[{i}]: missing 'priority'"))?;
let priority = TodoPriority::from_str(priority_str).ok_or_else(|| {
anyhow::anyhow!("todos[{i}]: invalid priority '{priority_str}' — use high/medium/low")
})?;
todos.push(TodoItem {
content,
status,
priority,
});
}
let in_progress = todos
.iter()
.filter(|t| t.status == TodoStatus::InProgress)
.count();
if in_progress > 1 {
anyhow::bail!(
"Invalid todo list: {in_progress} tasks marked 'in_progress'. \
Only one task may be 'in_progress' at a time — mark all but one as \
'pending' or 'completed' and call TodoWrite again."
);
}
let old: Vec<TodoItem> = match db.get_todo(session_id).await {
Ok(Some(raw)) => serde_json::from_str(&raw).unwrap_or_default(),
_ => Vec::new(),
};
if old == todos {
return Ok(TodoWriteOutcome {
message: format!(
"Todo list unchanged ({} task{}). \
Do not call TodoWrite again unless you are changing a task's status or content.",
todos.len(),
if todos.len() == 1 { "" } else { "s" }
),
items: todos,
diff: TodoDiff::default(),
});
}
let diff = TodoDiff::compute(&old, &todos);
let json = serde_json::to_string(&todos)?;
db.set_todo(session_id, &json).await?;
Ok(TodoWriteOutcome {
message: format_todo_list(&todos),
items: todos,
diff,
})
}
fn format_item(t: &TodoItem) -> String {
format!("{} {}", t.status.checkbox(), t.content)
}
fn format_todo_list(todos: &[TodoItem]) -> String {
if todos.is_empty() {
return "Todo list cleared.".to_string();
}
let completed = todos
.iter()
.filter(|t| t.status == TodoStatus::Completed)
.count();
let mut out = format!("Todo list updated ({}/{} done):\n", completed, todos.len(),);
for t in todos {
out.push_str(&format!(" {}{}\n", format_item(t), t.priority.suffix()));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
async fn test_db() -> (Database, TempDir, String) {
let dir = TempDir::new().unwrap();
let db = Database::open(&dir.path().join("test.db")).await.unwrap();
use crate::persistence::Persistence;
let sid = db.create_session("koda", dir.path()).await.unwrap();
(db, dir, sid)
}
#[tokio::test]
async fn write_and_read_back() {
let (db, _dir, sid) = test_db().await;
let args = json!({
"todos": [
{"content": "Add tests", "status": "pending", "priority": "high"},
{"content": "Write docs", "status": "in_progress", "priority": "medium"},
]
});
let out = todo_write(&db, &sid, &args).await.unwrap();
assert!(out.message.contains("0/2 done"));
assert!(out.message.contains("[ ] Add tests"));
assert!(out.message.contains("[→] Write docs"));
use crate::persistence::Persistence;
let raw = db.get_todo(&sid).await.unwrap().expect("row persisted");
assert!(raw.contains("Add tests"));
assert!(raw.contains("Write docs"));
}
#[tokio::test]
async fn empty_list_clears_todos() {
let (db, _dir, sid) = test_db().await;
let args = json!({ "todos": [
{"content": "Task", "status": "pending", "priority": "low"}
]});
todo_write(&db, &sid, &args).await.unwrap();
let clear = json!({ "todos": [] });
let out = todo_write(&db, &sid, &clear).await.unwrap();
assert!(out.message.contains("cleared"));
use crate::persistence::Persistence;
let raw = db.get_todo(&sid).await.unwrap().expect("row persisted");
assert_eq!(raw, "[]");
}
#[tokio::test]
async fn invalid_status_returns_error() {
let (db, _dir, sid) = test_db().await;
let args = json!({
"todos": [{"content": "Task", "status": "doing", "priority": "high"}]
});
let err = todo_write(&db, &sid, &args).await.unwrap_err();
assert!(err.to_string().contains("invalid status"));
}
#[tokio::test]
async fn invalid_priority_returns_error() {
let (db, _dir, sid) = test_db().await;
let args = json!({
"todos": [{"content": "Task", "status": "pending", "priority": "urgent"}]
});
let err = todo_write(&db, &sid, &args).await.unwrap_err();
assert!(err.to_string().contains("invalid priority"));
}
#[tokio::test]
async fn empty_content_returns_error() {
let (db, _dir, sid) = test_db().await;
let args = json!({
"todos": [{"content": " ", "status": "pending", "priority": "low"}]
});
let err = todo_write(&db, &sid, &args).await.unwrap_err();
assert!(err.to_string().contains("non-empty"));
}
#[tokio::test]
async fn missing_todos_field_returns_error() {
let (db, _dir, sid) = test_db().await;
let err = todo_write(&db, &sid, &json!({})).await.unwrap_err();
assert!(err.to_string().contains("todos"));
}
#[test]
fn format_single_task() {
let todos = vec![TodoItem {
content: "Ship it".into(),
status: TodoStatus::InProgress,
priority: TodoPriority::High,
}];
let out = format_todo_list(&todos);
assert!(out.contains("0/1 done"));
assert!(out.contains("[→] Ship it"));
assert!(out.contains("⚡"));
}
#[test]
fn format_completed_task() {
let todos = vec![
TodoItem {
content: "Done thing".into(),
status: TodoStatus::Completed,
priority: TodoPriority::Medium,
},
TodoItem {
content: "Todo thing".into(),
status: TodoStatus::Pending,
priority: TodoPriority::Low,
},
];
let out = format_todo_list(&todos);
assert!(out.contains("1/2 done"));
assert!(out.contains("[x] Done thing"));
assert!(out.contains("[ ] Todo thing"));
assert!(!out.contains("⚡") || !out.contains("Done thing ⚡"));
}
#[test]
fn status_checkbox_coverage() {
assert_eq!(TodoStatus::Pending.checkbox(), "[ ]");
assert_eq!(TodoStatus::InProgress.checkbox(), "[→]");
assert_eq!(TodoStatus::Completed.checkbox(), "[x]");
}
#[tokio::test]
async fn dedup_skips_identical_write() {
let (db, _dir, sid) = test_db().await;
let args = json!({
"todos": [
{"content": "Task A", "status": "pending", "priority": "high"},
{"content": "Task B", "status": "in_progress", "priority": "medium"},
]
});
let out1 = todo_write(&db, &sid, &args).await.unwrap();
assert!(out1.message.contains("0/2 done"));
let out2 = todo_write(&db, &sid, &args).await.unwrap();
assert!(
out2.message.contains("unchanged"),
"identical call should return 'unchanged', got: {}",
out2.message
);
assert!(
out2.message.contains("Do not call TodoWrite again"),
"should tell model to stop calling"
);
assert!(
out2.diff.is_empty(),
"unchanged write must yield an empty diff so the dispatch \
layer suppresses the TodoUpdate event"
);
}
#[tokio::test]
async fn dedup_allows_status_change() {
let (db, _dir, sid) = test_db().await;
let args1 = json!({
"todos": [
{"content": "Task A", "status": "pending", "priority": "high"},
]
});
todo_write(&db, &sid, &args1).await.unwrap();
let args2 = json!({
"todos": [
{"content": "Task A", "status": "completed", "priority": "high"},
]
});
let out = todo_write(&db, &sid, &args2).await.unwrap();
assert!(
out.message.contains("1/1 done"),
"status change should write normally, got: {}",
out.message
);
assert!(out.message.contains("[x] Task A"));
}
#[tokio::test]
async fn rejects_two_in_progress_items() {
let (db, _dir, sid) = test_db().await;
let args = json!({
"todos": [
{"content": "A", "status": "in_progress", "priority": "high"},
{"content": "B", "status": "in_progress", "priority": "medium"},
]
});
let err = todo_write(&db, &sid, &args).await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Only one task"), "got: {msg}");
assert!(msg.contains("in_progress"), "got: {msg}");
use crate::persistence::Persistence;
assert!(
db.get_todo(&sid).await.unwrap().is_none(),
"failed validation must not touch the DB"
);
}
#[tokio::test]
async fn accepts_single_in_progress() {
let (db, _dir, sid) = test_db().await;
let args = json!({
"todos": [
{"content": "A", "status": "in_progress", "priority": "high"},
{"content": "B", "status": "pending", "priority": "medium"},
{"content": "C", "status": "pending", "priority": "low"},
]
});
let out = todo_write(&db, &sid, &args).await.unwrap();
assert!(out.message.contains("0/3 done"));
}
#[tokio::test]
async fn accepts_zero_in_progress() {
let (db, _dir, sid) = test_db().await;
let args = json!({
"todos": [
{"content": "A", "status": "pending", "priority": "high"},
{"content": "B", "status": "pending", "priority": "medium"},
]
});
let out = todo_write(&db, &sid, &args).await.unwrap();
assert!(out.message.contains("0/2 done"));
}
fn item(content: &str, status: TodoStatus, priority: TodoPriority) -> TodoItem {
TodoItem {
content: content.into(),
status,
priority,
}
}
#[test]
fn diff_first_write_lands_everything_in_added() {
let new = vec![
item("A", TodoStatus::Pending, TodoPriority::High),
item("B", TodoStatus::InProgress, TodoPriority::Medium),
];
let diff = TodoDiff::compute(&[], &new);
assert_eq!(diff.added.len(), 2);
assert!(diff.changed.is_empty());
assert!(diff.removed.is_empty());
assert!(!diff.is_empty());
}
#[test]
fn diff_clear_lands_everything_in_removed() {
let old = vec![
item("A", TodoStatus::Pending, TodoPriority::High),
item("B", TodoStatus::Completed, TodoPriority::Medium),
];
let diff = TodoDiff::compute(&old, &[]);
assert!(diff.added.is_empty());
assert!(diff.changed.is_empty());
assert_eq!(diff.removed.len(), 2);
}
#[test]
fn diff_status_change_lands_in_changed() {
let old = vec![item("A", TodoStatus::Pending, TodoPriority::High)];
let new = vec![item("A", TodoStatus::InProgress, TodoPriority::High)];
let diff = TodoDiff::compute(&old, &new);
assert!(diff.added.is_empty());
assert!(diff.removed.is_empty());
assert_eq!(diff.changed.len(), 1);
assert_eq!(diff.changed[0].before.status, TodoStatus::Pending);
assert_eq!(diff.changed[0].after.status, TodoStatus::InProgress);
}
#[test]
fn diff_rename_lands_as_remove_plus_add() {
let old = vec![item("old name", TodoStatus::Pending, TodoPriority::High)];
let new = vec![item("new name", TodoStatus::Pending, TodoPriority::High)];
let diff = TodoDiff::compute(&old, &new);
assert_eq!(diff.added.len(), 1);
assert_eq!(diff.removed.len(), 1);
assert!(diff.changed.is_empty());
}
#[test]
fn diff_unchanged_item_does_not_surface() {
let old = vec![
item("A", TodoStatus::Pending, TodoPriority::High),
item("B", TodoStatus::InProgress, TodoPriority::Medium),
];
let new = vec![
item("A", TodoStatus::Pending, TodoPriority::High), item("B", TodoStatus::Completed, TodoPriority::Medium), ];
let diff = TodoDiff::compute(&old, &new);
assert!(diff.added.is_empty());
assert!(diff.removed.is_empty());
assert_eq!(diff.changed.len(), 1);
assert_eq!(diff.changed[0].after.content, "B");
}
#[test]
fn diff_priority_only_change_lands_in_changed() {
let old = vec![item("A", TodoStatus::Pending, TodoPriority::Low)];
let new = vec![item("A", TodoStatus::Pending, TodoPriority::High)];
let diff = TodoDiff::compute(&old, &new);
assert_eq!(diff.changed.len(), 1);
assert_eq!(diff.changed[0].before.priority, TodoPriority::Low);
assert_eq!(diff.changed[0].after.priority, TodoPriority::High);
}
#[test]
fn diff_empty_when_lists_identical() {
let old = vec![item("A", TodoStatus::Pending, TodoPriority::High)];
let new = old.clone();
let diff = TodoDiff::compute(&old, &new);
assert!(diff.is_empty(), "identical lists must produce no diff");
}
#[tokio::test]
async fn outcome_items_populated_on_dedup_path() {
let (db, _dir, sid) = test_db().await;
let args = json!({
"todos": [
{"content": "A", "status": "pending", "priority": "high"},
]
});
todo_write(&db, &sid, &args).await.unwrap();
let out2 = todo_write(&db, &sid, &args).await.unwrap();
assert!(out2.diff.is_empty(), "dedup must yield empty diff");
assert_eq!(out2.items.len(), 1, "dedup must still populate items");
assert_eq!(out2.items[0].content, "A");
}
#[tokio::test]
async fn outcome_first_write_yields_added_diff() {
let (db, _dir, sid) = test_db().await;
let args = json!({
"todos": [
{"content": "A", "status": "pending", "priority": "high"},
{"content": "B", "status": "in_progress", "priority": "medium"},
]
});
let out = todo_write(&db, &sid, &args).await.unwrap();
assert!(!out.diff.is_empty());
assert_eq!(out.diff.added.len(), 2);
assert!(out.diff.removed.is_empty());
assert!(out.diff.changed.is_empty());
assert_eq!(out.items.len(), 2);
}
}