claudette 0.8.1

Local-first AI personal secretary for Ollama. Telegram bot, voice, persistent scheduler, Gmail and Calendar. Single-binary Rust.
Documentation
//! Todos group — 4 tools (todo_add, todo_list, todo_set_status,
//! todo_delete). v0.6.0 merged the old todo_complete + todo_uncomplete
//! pair into the polymorphic todo_set_status(id, done) — the old names
//! still dispatch via aliases for one release.
//!
//! Storage: a single `todos.json` file under `~/.claudette/`. Reads the
//! whole file on every call and writes it back — fine at personal-agent
//! volume, no need for incremental updates or a DB.
//!
//! Self-contained: `todos_path` (pub(super) so get_capabilities can
//! show it), the `Todo` struct, and the `load_todos` / `save_todos`
//! helpers are private. Handlers reuse the parent-module `ensure_dir`
//! and `claudette_home` helpers.

use std::fs;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

use super::{claudette_home, ensure_dir};

pub(super) fn todos_path() -> PathBuf {
    claudette_home().join("todos.json")
}

#[derive(Serialize, Deserialize, Clone)]
struct Todo {
    id: String,
    text: String,
    done: bool,
    created_at: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    completed_at: Option<String>,
}

fn load_todos() -> Result<Vec<Todo>, String> {
    let path = todos_path();
    if !path.exists() {
        return Ok(Vec::new());
    }
    let s = fs::read_to_string(&path).map_err(|e| format!("read todos: {e}"))?;
    if s.trim().is_empty() {
        return Ok(Vec::new());
    }
    serde_json::from_str(&s).map_err(|e| format!("parse todos.json: {e}"))
}

fn save_todos(todos: &[Todo]) -> Result<(), String> {
    ensure_dir(&claudette_home())?;
    let s = serde_json::to_string_pretty(todos).map_err(|e| format!("serialize todos: {e}"))?;
    fs::write(todos_path(), s).map_err(|e| format!("write todos: {e}"))
}

pub(super) fn schemas() -> Vec<Value> {
    vec![
        json!({
            "type": "function",
            "function": {
                "name": "todo_add",
                "description": "Add a task to the todo list.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "text": { "type": "string", "description": "Task description" }
                    },
                    "required": ["text"]
                }
            }
        }),
        json!({
            "type": "function",
            "function": {
                "name": "todo_list",
                "description": "List todos with their status and IDs. By default lists all; pass pending_only to hide completed.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "pending_only": { "type": "boolean", "description": "If true, hide completed todos (default false)" }
                    },
                    "required": []
                }
            }
        }),
        json!({
            "type": "function",
            "function": {
                "name": "todo_set_status",
                "description": "Mark a todo done or un-done by its ID. Set done=true to complete, done=false to revert.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "id":   { "type": "string", "description": "Todo ID from todo_list" },
                        "done": { "type": "boolean", "description": "true to mark complete, false to revert to pending" }
                    },
                    "required": ["id", "done"]
                }
            }
        }),
        json!({
            "type": "function",
            "function": {
                "name": "todo_delete",
                "description": "Delete a todo by its ID. This is irreversible.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "id": { "type": "string", "description": "Todo ID from todo_list" }
                    },
                    "required": ["id"]
                }
            }
        }),
    ]
}

pub(super) fn dispatch(name: &str, input: &str) -> Option<Result<String, String>> {
    let result = match name {
        "todo_add" => run_todo_add(input),
        "todo_list" => run_todo_list(input),
        "todo_set_status" => run_todo_set_status(input),
        // v0.6.0 deprecated aliases — drop in next minor release.
        "todo_complete" => run_todo_complete_alias(input),
        "todo_uncomplete" => run_todo_uncomplete_alias(input),
        "todo_delete" => run_todo_delete(input),
        _ => return None,
    };
    Some(result)
}

fn run_todo_add(input: &str) -> Result<String, String> {
    let v: Value = serde_json::from_str(input)
        .map_err(|e| format!("todo_add: invalid JSON ({e}): {input}"))?;
    // Prefer "text"; accept "content" as a fallback for older prompts.
    let text = v
        .get("text")
        .or_else(|| v.get("content"))
        .and_then(Value::as_str)
        .ok_or("todo_add: missing 'text'")?
        .trim()
        .to_string();
    if text.is_empty() {
        return Err("todo_add: 'text' cannot be empty".to_string());
    }

    let mut todos = load_todos()?;
    let now = chrono::Local::now();
    let id = format!("t_{}", now.timestamp_millis());
    todos.push(Todo {
        id: id.clone(),
        text: text.clone(),
        done: false,
        created_at: now.to_rfc3339(),
        completed_at: None,
    });
    save_todos(&todos)?;

    Ok(json!({ "ok": true, "id": id, "text": text }).to_string())
}

fn run_todo_list(input: &str) -> Result<String, String> {
    let v: Value = serde_json::from_str(input).unwrap_or(json!({}));
    let pending_only = v
        .get("pending_only")
        .and_then(Value::as_bool)
        .unwrap_or(false);

    let todos = load_todos()?;
    let total = todos.len();
    let pending = todos.iter().filter(|t| !t.done).count();
    let view: Vec<Value> = todos
        .iter()
        .enumerate()
        .filter(|(_, t)| !pending_only || !t.done)
        .map(|(i, t)| {
            let mut obj = json!({
                "index": i + 1,
                "id": t.id,
                "text": t.text,
                "done": t.done,
                "created_at": t.created_at,
            });
            if let Some(ref c) = t.completed_at {
                obj["completed_at"] = json!(c);
            }
            obj
        })
        .collect();
    let mut result = json!({
        "count": view.len(),
        "total": total,
        "pending": pending,
        "todos": view,
    });
    if pending_only {
        result["pending_only"] = json!(true);
    }
    Ok(result.to_string())
}

/// `todo_set_status(id, done)` — flip a todo's done flag either direction.
/// Replaces todo_complete + todo_uncomplete.
fn run_todo_set_status(input: &str) -> Result<String, String> {
    let v: Value = serde_json::from_str(input)
        .map_err(|e| format!("todo_set_status: invalid JSON ({e}): {input}"))?;
    let id = v
        .get("id")
        .and_then(Value::as_str)
        .ok_or("todo_set_status: missing 'id'")?
        .to_string();
    let done = v
        .get("done")
        .and_then(Value::as_bool)
        .ok_or("todo_set_status: missing 'done' (boolean)")?;

    let mut todos = load_todos()?;
    let mut updated = None;
    for t in &mut todos {
        if t.id == id {
            t.done = done;
            t.completed_at = if done {
                Some(chrono::Local::now().to_rfc3339())
            } else {
                None
            };
            updated = Some(t.text.clone());
            break;
        }
    }
    let text = updated.ok_or_else(|| format!("todo_set_status: no todo with id '{id}'"))?;
    save_todos(&todos)?;

    Ok(json!({ "ok": true, "id": id, "text": text, "done": done }).to_string())
}

/// Backwards-compat shim for the old `todo_complete` shape (`{id}`).
/// Forwards to `todo_set_status` with `done=true`. Drop in the next
/// minor release after v0.6.0.
fn run_todo_complete_alias(input: &str) -> Result<String, String> {
    let v: Value = serde_json::from_str(input)
        .map_err(|e| format!("todo_complete: invalid JSON ({e}): {input}"))?;
    let id = v
        .get("id")
        .and_then(Value::as_str)
        .ok_or("todo_complete: missing 'id'")?;
    let payload = json!({ "id": id, "done": true });
    run_todo_set_status(&payload.to_string())
}

/// Backwards-compat shim for the old `todo_uncomplete` shape (`{id}`).
/// Forwards to `todo_set_status` with `done=false`. Drop in the next
/// minor release after v0.6.0.
fn run_todo_uncomplete_alias(input: &str) -> Result<String, String> {
    let v: Value = serde_json::from_str(input)
        .map_err(|e| format!("todo_uncomplete: invalid JSON ({e}): {input}"))?;
    let id = v
        .get("id")
        .and_then(Value::as_str)
        .ok_or("todo_uncomplete: missing 'id'")?;
    let payload = json!({ "id": id, "done": false });
    run_todo_set_status(&payload.to_string())
}

fn run_todo_delete(input: &str) -> Result<String, String> {
    let v: Value = serde_json::from_str(input)
        .map_err(|e| format!("todo_delete: invalid JSON ({e}): {input}"))?;
    let id = v
        .get("id")
        .and_then(Value::as_str)
        .ok_or("todo_delete: missing 'id'")?
        .to_string();

    let mut todos = load_todos()?;
    let before = todos.len();
    let removed_text = todos.iter().find(|t| t.id == id).map(|t| t.text.clone());
    todos.retain(|t| t.id != id);
    if todos.len() == before {
        return Err(format!("todo_delete: no todo with id '{id}'"));
    }
    save_todos(&todos)?;

    Ok(json!({
        "ok": true,
        "id": id,
        "text": removed_text.unwrap_or_default(),
        "deleted": true,
    })
    .to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn todo_add_rejects_empty_text() {
        let err = run_todo_add(r#"{"text":""}"#).unwrap_err();
        assert!(err.contains("empty"), "got: {err}");
    }

    #[test]
    fn todo_add_rejects_whitespace_only_text() {
        let err = run_todo_add(r#"{"text":"   "}"#).unwrap_err();
        assert!(err.contains("empty"), "got: {err}");
    }

    #[test]
    fn todo_add_rejects_missing_text() {
        let err = run_todo_add("{}").unwrap_err();
        assert!(err.contains("missing 'text'"), "got: {err}");
    }

    #[test]
    fn todo_set_status_rejects_missing_id() {
        let err = run_todo_set_status(r#"{"done":true}"#).unwrap_err();
        assert!(err.contains("missing 'id'"), "got: {err}");
    }

    #[test]
    fn todo_set_status_rejects_missing_done() {
        let err = run_todo_set_status(r#"{"id":"t_x"}"#).unwrap_err();
        assert!(err.contains("missing 'done'"), "got: {err}");
    }

    #[test]
    fn todo_set_status_rejects_unknown_id() {
        let err =
            run_todo_set_status(r#"{"id":"t_does_not_exist_99999","done":true}"#).unwrap_err();
        assert!(err.contains("no todo with id"), "got: {err}");
    }

    #[test]
    fn todo_complete_alias_dispatches() {
        // Alias must still route through dispatch — execution would touch
        // disk, so we only verify the dispatch branch is wired by trying
        // an unknown id (which gets through arg validation, then errors
        // at the lookup step).
        let result = dispatch("todo_complete", r#"{"id":"t_unknown_xyz_123"}"#);
        assert!(result.is_some(), "todo_complete alias must dispatch");
        let err = result.unwrap().unwrap_err();
        assert!(err.contains("no todo with id"), "got: {err}");
    }

    #[test]
    fn todo_uncomplete_alias_dispatches() {
        let result = dispatch("todo_uncomplete", r#"{"id":"t_unknown_xyz_123"}"#);
        assert!(result.is_some(), "todo_uncomplete alias must dispatch");
        let err = result.unwrap().unwrap_err();
        assert!(err.contains("no todo with id"), "got: {err}");
    }

    #[test]
    fn todo_complete_alias_rejects_missing_id() {
        let err = run_todo_complete_alias("{}").unwrap_err();
        assert!(err.contains("missing 'id'"), "got: {err}");
    }

    #[test]
    fn todo_uncomplete_alias_rejects_missing_id() {
        let err = run_todo_uncomplete_alias("{}").unwrap_err();
        assert!(err.contains("missing 'id'"), "got: {err}");
    }

    #[test]
    fn todo_delete_rejects_missing_id() {
        let err = run_todo_delete("{}").unwrap_err();
        assert!(err.contains("missing 'id'"), "got: {err}");
    }

    #[test]
    fn todo_delete_rejects_unknown_id() {
        let err = run_todo_delete(r#"{"id":"t_does_not_exist_99999"}"#).unwrap_err();
        assert!(err.contains("no todo with id"), "got: {err}");
    }

    #[test]
    fn todo_list_pending_only_flag_passes_through() {
        // Schema accepts pending_only: bool; result reflects it.
        let out = run_todo_list(r#"{"pending_only":true}"#).expect("ok");
        let v: Value = serde_json::from_str(&out).unwrap();
        assert!(v["total"].is_number());
        assert!(v["pending"].is_number());
        assert_eq!(v["pending_only"], Value::Bool(true));
    }

    #[test]
    fn schemas_lists_four_tools() {
        let schemas = schemas();
        assert_eq!(schemas.len(), 4);
        let names: Vec<&str> = schemas
            .iter()
            .filter_map(|v| v.pointer("/function/name").and_then(Value::as_str))
            .collect();
        assert_eq!(
            names,
            ["todo_add", "todo_list", "todo_set_status", "todo_delete"]
        );
    }
}