llm_coding_tools_core/operations/
todo.rs

1//! Todo list management operation.
2//!
3//! This module is only available with the `async` feature.
4
5use crate::error::{ToolError, ToolResult};
6use parking_lot::RwLock;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::fmt::Write;
10use std::sync::Arc;
11
12/// Task status.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
14#[serde(rename_all = "snake_case")]
15pub enum TodoStatus {
16    /// Not yet started.
17    Pending,
18    /// Currently being worked on.
19    InProgress,
20    /// Successfully finished.
21    Completed,
22    /// Abandoned or no longer relevant.
23    Cancelled,
24}
25
26impl TodoStatus {
27    /// Returns the status indicator icon.
28    #[inline]
29    pub const fn icon(self) -> &'static str {
30        match self {
31            Self::Pending => "[ ]",
32            Self::InProgress => "[>]",
33            Self::Completed => "[x]",
34            Self::Cancelled => "[-]",
35        }
36    }
37}
38
39/// Task priority level.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
41#[serde(rename_all = "snake_case")]
42pub enum TodoPriority {
43    /// Urgent, should be addressed first.
44    High,
45    /// Normal priority.
46    Medium,
47    /// Can be deferred.
48    Low,
49}
50
51/// A single task item.
52#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
53pub struct Todo {
54    /// Unique identifier for the task.
55    pub id: String,
56    /// Task description.
57    pub content: String,
58    /// Current status.
59    pub status: TodoStatus,
60    /// Priority level.
61    pub priority: TodoPriority,
62}
63
64/// Thread-safe shared state for todo list.
65#[derive(Debug, Clone, Default)]
66pub struct TodoState {
67    todos: Arc<RwLock<Vec<Todo>>>,
68}
69
70impl TodoState {
71    /// Creates a new empty todo state.
72    #[inline]
73    pub fn new() -> Self {
74        Self::default()
75    }
76}
77
78/// Writes/replaces the todo list with new items.
79///
80/// Validates that all todos have non-empty id and content.
81pub fn write_todos(state: &TodoState, todos: Vec<Todo>) -> ToolResult<String> {
82    for todo in &todos {
83        if todo.id.trim().is_empty() {
84            return Err(ToolError::Validation("todo id cannot be empty".into()));
85        }
86        if todo.content.trim().is_empty() {
87            return Err(ToolError::Validation("todo content cannot be empty".into()));
88        }
89    }
90
91    let count = todos.len();
92    *state.todos.write() = todos;
93    Ok(format!("Updated todo list with {count} task(s)."))
94}
95
96/// Reads and formats the current todo list.
97pub fn read_todos(state: &TodoState) -> String {
98    let todos = state.todos.read();
99
100    if todos.is_empty() {
101        return "No tasks.".to_string();
102    }
103
104    let mut output = format!("Tasks ({} total):\n", todos.len());
105    for todo in todos.iter() {
106        let _ = writeln!(
107            output,
108            "{} ({:?}) {}: {}",
109            todo.status.icon(),
110            todo.priority,
111            todo.id,
112            todo.content
113        );
114    }
115
116    output.truncate(output.trim_end().len());
117    output
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    fn make_todo(id: &str, status: TodoStatus) -> Todo {
125        Todo {
126            id: id.to_string(),
127            content: format!("Task {id}"),
128            status,
129            priority: TodoPriority::Medium,
130        }
131    }
132
133    #[test]
134    fn write_and_read_todos() {
135        let state = TodoState::new();
136
137        let todos = vec![
138            make_todo("1", TodoStatus::Completed),
139            make_todo("2", TodoStatus::InProgress),
140            make_todo("3", TodoStatus::Pending),
141        ];
142
143        let result = write_todos(&state, todos).unwrap();
144        assert!(result.contains("3 task(s)"));
145
146        let output = read_todos(&state);
147        assert!(output.contains("[x]"));
148        assert!(output.contains("[>]"));
149        assert!(output.contains("[ ]"));
150    }
151
152    #[test]
153    fn read_empty_list() {
154        let state = TodoState::new();
155        let output = read_todos(&state);
156        assert_eq!(output, "No tasks.");
157    }
158
159    #[test]
160    fn write_replaces_existing() {
161        let state = TodoState::new();
162
163        write_todos(&state, vec![make_todo("a", TodoStatus::Pending)]).unwrap();
164        write_todos(&state, vec![make_todo("b", TodoStatus::Completed)]).unwrap();
165
166        let output = read_todos(&state);
167        assert!(!output.contains("Task a"));
168        assert!(output.contains("Task b"));
169    }
170
171    #[test]
172    fn write_validates_empty_id() {
173        let state = TodoState::new();
174        let todo = Todo {
175            id: "".to_string(),
176            content: "Task".to_string(),
177            status: TodoStatus::Pending,
178            priority: TodoPriority::Low,
179        };
180        let result = write_todos(&state, vec![todo]);
181        assert!(matches!(result, Err(ToolError::Validation(_))));
182    }
183
184    #[test]
185    fn write_validates_empty_content() {
186        let state = TodoState::new();
187        let todo = Todo {
188            id: "1".to_string(),
189            content: "  ".to_string(),
190            status: TodoStatus::Pending,
191            priority: TodoPriority::Low,
192        };
193        let result = write_todos(&state, vec![todo]);
194        assert!(matches!(result, Err(ToolError::Validation(_))));
195    }
196
197    #[test]
198    fn status_icons_are_correct() {
199        assert_eq!(TodoStatus::Pending.icon(), "[ ]");
200        assert_eq!(TodoStatus::InProgress.icon(), "[>]");
201        assert_eq!(TodoStatus::Completed.icon(), "[x]");
202        assert_eq!(TodoStatus::Cancelled.icon(), "[-]");
203    }
204
205    #[test]
206    fn status_serde_roundtrip() {
207        let json = serde_json::to_string(&TodoStatus::InProgress).unwrap();
208        assert_eq!(json, "\"in_progress\"");
209        let parsed: TodoStatus = serde_json::from_str(&json).unwrap();
210        assert_eq!(parsed, TodoStatus::InProgress);
211    }
212}