coyote-ai 0.8.3

An all-in-one, batteries included LLM CLI Tool
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TodoStatus {
    Pending,
    Done,
}

impl TodoStatus {
    fn icon(&self) -> &'static str {
        match self {
            TodoStatus::Pending => "",
            TodoStatus::Done => "",
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoItem {
    pub id: usize,
    #[serde(alias = "description")]
    pub desc: String,
    pub done: bool,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TodoList {
    #[serde(default)]
    pub goal: String,
    #[serde(default)]
    pub todos: Vec<TodoItem>,
}

impl TodoList {
    pub fn new(goal: &str) -> Self {
        Self {
            goal: goal.to_string(),
            todos: Vec::new(),
        }
    }

    pub fn add(&mut self, task: &str) -> usize {
        let id = self.todos.iter().map(|t| t.id).max().unwrap_or(0) + 1;
        self.todos.push(TodoItem {
            id,
            desc: task.to_string(),
            done: false,
        });
        id
    }

    pub fn mark_done(&mut self, id: usize) -> bool {
        if let Some(item) = self.todos.iter_mut().find(|t| t.id == id) {
            item.done = true;
            true
        } else {
            false
        }
    }

    pub fn has_incomplete(&self) -> bool {
        self.todos.iter().any(|item| !item.done)
    }

    pub fn is_empty(&self) -> bool {
        self.todos.is_empty()
    }

    pub fn clear(&mut self) {
        self.goal.clear();
        self.todos.clear();
    }

    pub fn render_for_model(&self) -> String {
        let mut lines = Vec::new();
        if !self.goal.is_empty() {
            lines.push(format!("Goal: {}", self.goal));
        }
        lines.push(format!(
            "Progress: {}/{} completed",
            self.completed_count(),
            self.todos.len()
        ));
        for item in &self.todos {
            let status = if item.done {
                TodoStatus::Done
            } else {
                TodoStatus::Pending
            };
            lines.push(format!("  {} {}. {}", status.icon(), item.id, item.desc));
        }
        lines.join("\n")
    }

    pub fn incomplete_count(&self) -> usize {
        self.todos.iter().filter(|item| !item.done).count()
    }

    pub fn completed_count(&self) -> usize {
        self.todos.iter().filter(|item| item.done).count()
    }
}

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

    #[test]
    fn test_new_and_add() {
        let mut list = TodoList::new("Map Labs");
        assert_eq!(list.add("Discover"), 1);
        assert_eq!(list.add("Map columns"), 2);
        assert_eq!(list.todos.len(), 2);
        assert!(list.has_incomplete());
    }

    #[test]
    fn test_mark_done() {
        let mut list = TodoList::new("Test");
        list.add("Task 1");
        list.add("Task 2");
        assert!(list.mark_done(1));
        assert!(!list.mark_done(99));
        assert_eq!(list.completed_count(), 1);
        assert_eq!(list.incomplete_count(), 1);
    }

    #[test]
    fn test_empty_list() {
        let list = TodoList::default();
        assert!(!list.has_incomplete());
        assert!(list.is_empty());
    }

    #[test]
    fn test_all_done() {
        let mut list = TodoList::new("Test");
        list.add("Done task");
        list.mark_done(1);
        assert!(!list.has_incomplete());
    }

    #[test]
    fn test_render_for_model() {
        let mut list = TodoList::new("Map Labs");
        list.add("Discover");
        list.add("Map");
        list.mark_done(1);
        let rendered = list.render_for_model();
        assert!(rendered.contains("Goal: Map Labs"));
        assert!(rendered.contains("Progress: 1/2 completed"));
        assert!(rendered.contains("✓ 1. Discover"));
        assert!(rendered.contains("○ 2. Map"));
    }

    #[test]
    fn test_clear() {
        let mut list = TodoList::new("Some goal");
        list.add("Task 1");
        list.add("Task 2");
        list.mark_done(1);
        assert!(!list.is_empty());

        list.clear();
        assert!(list.is_empty());
        assert!(list.goal.is_empty());
        assert_eq!(list.todos.len(), 0);
        assert!(!list.has_incomplete());
    }

    #[test]
    fn test_serialization_roundtrip() {
        let mut list = TodoList::new("Roundtrip");
        list.add("Step 1");
        list.add("Step 2");
        list.mark_done(1);
        let json = serde_json::to_string(&list).unwrap();
        let deserialized: TodoList = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized.goal, "Roundtrip");
        assert_eq!(deserialized.todos.len(), 2);
        assert!(deserialized.todos[0].done);
        assert!(!deserialized.todos[1].done);
    }
}