use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TodoStatus {
Open,
Done,
Dropped,
}
impl TodoStatus {
fn marker(self) -> char {
match self {
TodoStatus::Open => ' ',
TodoStatus::Done => 'x',
TodoStatus::Dropped => '-',
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoItem {
pub id: usize,
pub text: String,
pub status: TodoStatus,
}
#[derive(Debug, Default)]
pub struct TodoList {
items: Vec<TodoItem>,
}
const MAX_ITEMS: usize = 40;
const MAX_TEXT: usize = 200;
impl TodoList {
pub fn new() -> Self {
Self::default()
}
pub fn write(&mut self, items: &[Value]) -> Result<(), String> {
if items.len() > MAX_ITEMS {
return Err(format!(
"too many items ({}); keep the plan under {MAX_ITEMS} — a list \
longer than that is a sign the task needs decomposing into \
sub-runs, not a longer checklist",
items.len()
));
}
let mut parsed = Vec::with_capacity(items.len());
for (i, raw) in items.iter().enumerate() {
let text = raw
.get("text")
.and_then(Value::as_str)
.ok_or_else(|| format!("item {i} has no `text`"))?
.trim();
if text.is_empty() {
return Err(format!("item {i} has empty `text`"));
}
let status = match raw.get("status").and_then(Value::as_str) {
None => TodoStatus::Open,
Some("open") => TodoStatus::Open,
Some("done") => TodoStatus::Done,
Some("dropped") => TodoStatus::Dropped,
Some(other) => {
return Err(format!(
"item {i} has unknown status '{other}'; use open, done, or dropped"
))
}
};
parsed.push(TodoItem {
id: i + 1,
text: super::value_store::clip_str(text, MAX_TEXT),
status,
});
}
self.items = parsed;
Ok(())
}
pub fn items(&self) -> &[TodoItem] {
&self.items
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn render(&self) -> Option<String> {
if self.items.is_empty() {
return None;
}
let done = self
.items
.iter()
.filter(|i| i.status == TodoStatus::Done)
.count();
let dropped = self
.items
.iter()
.filter(|i| i.status == TodoStatus::Dropped)
.count();
let total = self.items.len();
let mut out = format!("todo: {done}/{total} done");
if dropped > 0 {
out.push_str(&format!(", {dropped} dropped"));
}
let open: Vec<&TodoItem> = self
.items
.iter()
.filter(|i| i.status == TodoStatus::Open)
.collect();
if open.is_empty() {
out.push_str(" — nothing open");
} else {
for item in open {
out.push_str(&format!(
"\n [{}] {} {}",
item.status.marker(),
item.id,
item.text
));
}
}
Some(out)
}
}
pub fn tool_def() -> Value {
json!({
"name": "todo_write",
"description": "Record or update the plan for this task as a checklist. \
Write the WHOLE list each time — it replaces the previous \
one. Use it on any task with more than a couple of steps: \
the list is live state, so unlike the transcript it \
survives history compaction, and it is how you know what \
is left after earlier turns are dropped. Returns the \
current status.",
"parameters": {
"type": "object",
"properties": {
"items": {
"type": "array",
"description": "The complete checklist, in order.",
"items": {
"type": "object",
"properties": {
"text": { "type": "string", "description": "What the step is." },
"status": {
"type": "string",
"enum": ["open", "done", "dropped"],
"description": "Defaults to open. Use 'dropped' for a step \
deliberately abandoned — it is not the same \
as done."
}
},
"required": ["text"]
}
}
},
"required": ["items"]
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn items(specs: &[(&str, &str)]) -> Vec<Value> {
specs
.iter()
.map(|(text, status)| json!({"text": text, "status": status}))
.collect()
}
#[test]
fn render_counts_progress_and_lists_only_what_is_open() {
let mut list = TodoList::new();
list.write(&items(&[
("read the spec", "done"),
("write the parser", "done"),
("wire the CLI", "open"),
("benchmark it", "open"),
]))
.unwrap();
let render = list.render().expect("a non-empty list renders");
assert!(render.starts_with("todo: 2/4 done"), "{render}");
assert!(
render.contains("wire the CLI"),
"open items listed: {render}"
);
assert!(
!render.contains("read the spec"),
"completed items are counted, not listed — they change no decision: {render}"
);
}
#[test]
fn dropped_is_reported_separately_from_done() {
let mut list = TodoList::new();
list.write(&items(&[
("try approach A", "dropped"),
("try approach B", "done"),
("ship it", "open"),
]))
.unwrap();
let render = list.render().unwrap();
assert!(render.contains("1/3 done"), "{render}");
assert!(render.contains("1 dropped"), "{render}");
}
#[test]
fn writing_replaces_rather_than_appends() {
let mut list = TodoList::new();
list.write(&items(&[("first plan", "open")])).unwrap();
list.write(&items(&[("second plan", "open")])).unwrap();
assert_eq!(list.items().len(), 1);
assert_eq!(list.items()[0].text, "second plan");
assert_eq!(list.items()[0].id, 1);
}
#[test]
fn an_empty_list_renders_nothing_rather_than_an_empty_header() {
assert_eq!(TodoList::new().render(), None);
}
#[test]
fn all_done_says_so_instead_of_listing_nothing() {
let mut list = TodoList::new();
list.write(&items(&[("a", "done"), ("b", "done")])).unwrap();
let render = list.render().unwrap();
assert!(render.contains("2/2 done"), "{render}");
assert!(render.contains("nothing open"), "{render}");
}
#[test]
fn malformed_items_are_rejected_with_actionable_errors() {
let mut list = TodoList::new();
assert!(list
.write(&[json!({"status": "open"})])
.unwrap_err()
.contains("no `text`"));
assert!(list
.write(&[json!({"text": " "})])
.unwrap_err()
.contains("empty `text`"));
let err = list
.write(&[json!({"text": "x", "status": "in_progress"})])
.unwrap_err();
assert!(err.contains("unknown status 'in_progress'"), "{err}");
assert!(
err.contains("open, done, or dropped"),
"the error must name the valid values: {err}"
);
assert!(list.is_empty());
}
#[test]
fn the_list_is_bounded() {
let mut list = TodoList::new();
let many: Vec<Value> = (0..MAX_ITEMS + 1)
.map(|i| json!({"text": i.to_string()}))
.collect();
assert!(list.write(&many).unwrap_err().contains("too many items"));
list.write(&[json!({"text": "y".repeat(MAX_TEXT + 500)})])
.unwrap();
assert!(list.items()[0].text.len() <= MAX_TEXT + 4);
}
}