llm_coding_tools_core/operations/
todo.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
14#[serde(rename_all = "snake_case")]
15pub enum TodoStatus {
16 Pending,
18 InProgress,
20 Completed,
22 Cancelled,
24}
25
26impl TodoStatus {
27 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
41#[serde(rename_all = "snake_case")]
42pub enum TodoPriority {
43 High,
45 Medium,
47 Low,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
53pub struct Todo {
54 pub id: String,
56 pub content: String,
58 pub status: TodoStatus,
60 pub priority: TodoPriority,
62}
63
64#[derive(Debug, Clone, Default)]
66pub struct TodoState {
67 todos: Arc<RwLock<Vec<Todo>>>,
68}
69
70impl TodoState {
71 #[inline]
73 pub fn new() -> Self {
74 Self::default()
75 }
76}
77
78pub 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
96pub 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}