1use crate::error::ToolError;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::PathBuf;
9use tracing::{debug, error, info};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum TodoStatus {
15 Pending,
17 InProgress,
19 Completed,
21 Blocked,
23}
24
25impl std::fmt::Display for TodoStatus {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 match self {
28 TodoStatus::Pending => write!(f, "pending"),
29 TodoStatus::InProgress => write!(f, "in_progress"),
30 TodoStatus::Completed => write!(f, "completed"),
31 TodoStatus::Blocked => write!(f, "blocked"),
32 }
33 }
34}
35
36impl std::str::FromStr for TodoStatus {
37 type Err = ToolError;
38
39 fn from_str(s: &str) -> Result<Self, Self::Err> {
40 match s.to_lowercase().as_str() {
41 "pending" => Ok(TodoStatus::Pending),
42 "in_progress" => Ok(TodoStatus::InProgress),
43 "completed" => Ok(TodoStatus::Completed),
44 "blocked" => Ok(TodoStatus::Blocked),
45 _ => Err(ToolError::new(
46 "INVALID_STATUS",
47 format!("Invalid todo status: {}", s),
48 )
49 .with_suggestion("Use one of: pending, in_progress, completed, blocked")),
50 }
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum TodoPriority {
58 Low,
60 Medium,
62 High,
64 Critical,
66}
67
68impl std::fmt::Display for TodoPriority {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 TodoPriority::Low => write!(f, "low"),
72 TodoPriority::Medium => write!(f, "medium"),
73 TodoPriority::High => write!(f, "high"),
74 TodoPriority::Critical => write!(f, "critical"),
75 }
76 }
77}
78
79impl std::str::FromStr for TodoPriority {
80 type Err = ToolError;
81
82 fn from_str(s: &str) -> Result<Self, Self::Err> {
83 match s.to_lowercase().as_str() {
84 "low" => Ok(TodoPriority::Low),
85 "medium" => Ok(TodoPriority::Medium),
86 "high" => Ok(TodoPriority::High),
87 "critical" => Ok(TodoPriority::Critical),
88 _ => Err(ToolError::new(
89 "INVALID_PRIORITY",
90 format!("Invalid todo priority: {}", s),
91 )
92 .with_suggestion("Use one of: low, medium, high, critical")),
93 }
94 }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
99pub struct Todo {
100 pub id: String,
102 pub title: String,
104 pub description: Option<String>,
106 pub status: TodoStatus,
108 pub priority: TodoPriority,
110}
111
112impl Todo {
113 pub fn new(
115 id: impl Into<String>,
116 title: impl Into<String>,
117 status: TodoStatus,
118 priority: TodoPriority,
119 ) -> Result<Self, ToolError> {
120 let title = title.into();
121
122 if title.trim().is_empty() {
124 return Err(ToolError::new("INVALID_TITLE", "Todo title cannot be empty")
125 .with_suggestion("Provide a non-empty title for the todo"));
126 }
127
128 Ok(Self {
129 id: id.into(),
130 title,
131 description: None,
132 status,
133 priority,
134 })
135 }
136
137 pub fn with_description(mut self, description: impl Into<String>) -> Self {
139 self.description = Some(description.into());
140 self
141 }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct TodowriteInput {
147 pub todos: Vec<Todo>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct TodowriteOutput {
154 pub created: usize,
156 pub updated: usize,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct TodoreadInput {
163 pub status_filter: Option<TodoStatus>,
165 pub priority_filter: Option<TodoPriority>,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct TodoreadOutput {
172 pub todos: Vec<Todo>,
174}
175
176pub struct TodoStorage {
178 storage_path: PathBuf,
179}
180
181impl TodoStorage {
182 pub fn new(storage_path: impl Into<PathBuf>) -> Self {
184 Self {
185 storage_path: storage_path.into(),
186 }
187 }
188
189 pub fn default_path() -> Result<PathBuf, ToolError> {
191 if let Some(home_dir) = dirs::home_dir() {
192 Ok(home_dir.join(".ricecoder").join("todos.json"))
193 } else {
194 Err(ToolError::new(
195 "HOME_DIR_NOT_FOUND",
196 "Could not determine home directory",
197 )
198 .with_suggestion("Set the HOME environment variable"))
199 }
200 }
201
202 pub fn load_todos(&self) -> Result<HashMap<String, Todo>, ToolError> {
204 debug!("Loading todos from: {:?}", self.storage_path);
205
206 if !self.storage_path.exists() {
208 debug!("Todo storage file does not exist, returning empty todos");
209 return Ok(HashMap::new());
210 }
211
212 let content = std::fs::read_to_string(&self.storage_path).map_err(|e| {
214 error!("Failed to read todo storage file: {}", e);
215 ToolError::from(e)
216 .with_details(format!("Failed to read: {:?}", self.storage_path))
217 .with_suggestion("Check file permissions and ensure the file is readable")
218 })?;
219
220 let todos: Vec<Todo> = serde_json::from_str(&content).map_err(|e| {
222 error!("Failed to parse todo storage file: {}", e);
223 ToolError::from(e)
224 .with_details("Todo storage file contains invalid JSON")
225 .with_suggestion("Check the file format or restore from backup")
226 })?;
227
228 let mut map = HashMap::new();
230 for todo in todos {
231 map.insert(todo.id.clone(), todo);
232 }
233
234 info!("Loaded {} todos from storage", map.len());
235 Ok(map)
236 }
237
238 pub fn save_todos(&self, todos: &HashMap<String, Todo>) -> Result<(), ToolError> {
240 debug!("Saving {} todos to: {:?}", todos.len(), self.storage_path);
241
242 if let Some(parent) = self.storage_path.parent() {
244 std::fs::create_dir_all(parent).map_err(|e| {
245 error!("Failed to create storage directory: {}", e);
246 ToolError::from(e)
247 .with_details(format!("Failed to create: {:?}", parent))
248 .with_suggestion("Check directory permissions")
249 })?;
250 }
251
252 let mut todos_vec: Vec<Todo> = todos.values().cloned().collect();
254 todos_vec.sort_by(|a, b| a.id.cmp(&b.id));
255
256 let json = serde_json::to_string_pretty(&todos_vec).map_err(|e| {
258 error!("Failed to serialize todos: {}", e);
259 ToolError::from(e)
260 .with_details("Failed to serialize todos to JSON")
261 .with_suggestion("Check for circular references or invalid data")
262 })?;
263
264 let temp_path = self.storage_path.with_extension("json.tmp");
266 std::fs::write(&temp_path, &json).map_err(|e| {
267 error!("Failed to write temporary todo file: {}", e);
268 ToolError::from(e)
269 .with_details(format!("Failed to write: {:?}", temp_path))
270 .with_suggestion("Check disk space and file permissions")
271 })?;
272
273 std::fs::rename(&temp_path, &self.storage_path).map_err(|e| {
275 error!("Failed to finalize todo storage: {}", e);
276 let _ = std::fs::remove_file(&temp_path);
278 ToolError::from(e)
279 .with_details(format!(
280 "Failed to rename: {:?} to {:?}",
281 temp_path, self.storage_path
282 ))
283 .with_suggestion("Check file permissions and disk space")
284 })?;
285
286 info!("Saved {} todos to storage", todos.len());
287 Ok(())
288 }
289}
290
291pub struct TodoTools {
293 storage: TodoStorage,
294 mcp_provider: Option<std::sync::Arc<dyn crate::Provider>>,
295}
296
297impl TodoTools {
298 pub fn new() -> Result<Self, ToolError> {
300 let storage_path = TodoStorage::default_path()?;
301 Ok(Self {
302 storage: TodoStorage::new(storage_path),
303 mcp_provider: None,
304 })
305 }
306
307 pub fn with_storage_path(storage_path: impl Into<PathBuf>) -> Self {
309 Self {
310 storage: TodoStorage::new(storage_path),
311 mcp_provider: None,
312 }
313 }
314
315 pub fn with_mcp_provider(mut self, provider: std::sync::Arc<dyn crate::Provider>) -> Self {
317 self.mcp_provider = Some(provider);
318 self
319 }
320
321 pub async fn write_todos_with_timeout(&self, input: TodowriteInput) -> Result<TodowriteOutput, ToolError> {
325 let timeout_duration = std::time::Duration::from_millis(500);
326
327 match tokio::time::timeout(timeout_duration, async {
328 self.write_todos_internal(input)
329 }).await {
330 Ok(result) => result,
331 Err(_) => {
332 Err(ToolError::new("TIMEOUT", "Todo write operation exceeded 500ms timeout")
333 .with_details("Operation took too long to complete")
334 .with_suggestion("Try again or check system performance"))
335 }
336 }
337 }
338
339 pub fn write_todos(&self, input: TodowriteInput) -> Result<TodowriteOutput, ToolError> {
343 self.write_todos_internal(input)
344 }
345
346 fn write_todos_internal(&self, input: TodowriteInput) -> Result<TodowriteOutput, ToolError> {
348 debug!("Writing {} todos", input.todos.len());
349
350 if let Some(_provider) = &self.mcp_provider {
352 debug!("Attempting to use MCP provider for todowrite");
353 let _input_json = serde_json::to_string(&input).map_err(|e| {
354 error!("Failed to serialize todowrite input: {}", e);
355 ToolError::from(e)
356 .with_details("Failed to serialize input for MCP provider")
357 .with_suggestion("Check the input data format")
358 })?;
359
360 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
361 None::<String>
364 })) {
365 Ok(_) => {
366 debug!("MCP provider not available, falling back to built-in");
367 }
368 Err(_) => {
369 debug!("MCP provider error, falling back to built-in");
370 }
371 }
372 }
373
374 debug!("Using built-in todowrite implementation");
376
377 let mut todos = self.storage.load_todos()?;
379
380 let mut created = 0;
381 let mut updated = 0;
382
383 for todo in input.todos {
385 let id = todo.id.clone();
386 if todos.contains_key(&id) {
387 updated += 1;
388 } else {
389 created += 1;
390 }
391 todos.insert(id, todo);
392 }
393
394 self.storage.save_todos(&todos)?;
396
397 info!("Wrote todos: {} created, {} updated", created, updated);
398 Ok(TodowriteOutput { created, updated })
399 }
400
401 pub async fn read_todos_with_timeout(&self, input: TodoreadInput) -> Result<TodoreadOutput, ToolError> {
405 let timeout_duration = std::time::Duration::from_millis(500);
406
407 match tokio::time::timeout(timeout_duration, async {
408 self.read_todos_internal(input)
409 }).await {
410 Ok(result) => result,
411 Err(_) => {
412 Err(ToolError::new("TIMEOUT", "Todo read operation exceeded 500ms timeout")
413 .with_details("Operation took too long to complete")
414 .with_suggestion("Try again or check system performance"))
415 }
416 }
417 }
418
419 pub fn read_todos(&self, input: TodoreadInput) -> Result<TodoreadOutput, ToolError> {
423 self.read_todos_internal(input)
424 }
425
426 fn read_todos_internal(&self, input: TodoreadInput) -> Result<TodoreadOutput, ToolError> {
428 debug!("Reading todos with filters: status={:?}, priority={:?}",
429 input.status_filter, input.priority_filter);
430
431 if let Some(_provider) = &self.mcp_provider {
433 debug!("Attempting to use MCP provider for todoread");
434 let _input_json = serde_json::to_string(&input).map_err(|e| {
435 error!("Failed to serialize todoread input: {}", e);
436 ToolError::from(e)
437 .with_details("Failed to serialize input for MCP provider")
438 .with_suggestion("Check the input data format")
439 })?;
440
441 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
442 None::<String>
445 })) {
446 Ok(_) => {
447 debug!("MCP provider not available, falling back to built-in");
448 }
449 Err(_) => {
450 debug!("MCP provider error, falling back to built-in");
451 }
452 }
453 }
454
455 debug!("Using built-in todoread implementation");
457
458 let todos = self.storage.load_todos()?;
460
461 let mut filtered: Vec<Todo> = todos
463 .into_values()
464 .filter(|todo| {
465 if let Some(status) = input.status_filter {
467 if todo.status != status {
468 return false;
469 }
470 }
471
472 if let Some(priority) = input.priority_filter {
474 if todo.priority != priority {
475 return false;
476 }
477 }
478
479 true
480 })
481 .collect();
482
483 filtered.sort_by(|a, b| {
485 match b.priority.cmp(&a.priority) {
486 std::cmp::Ordering::Equal => a.id.cmp(&b.id),
487 other => other,
488 }
489 });
490
491 info!("Read {} todos (filtered from total)", filtered.len());
492 Ok(TodoreadOutput { todos: filtered })
493 }
494}
495
496impl Default for TodoTools {
497 fn default() -> Self {
498 Self::new().unwrap_or_else(|_| {
499 Self::with_storage_path("/tmp/ricecoder-todos.json")
501 })
502 }
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use tempfile::TempDir;
509
510 #[test]
511 fn test_todo_creation() {
512 let todo = Todo::new("1", "Test todo", TodoStatus::Pending, TodoPriority::High);
513 assert!(todo.is_ok());
514 let todo = todo.unwrap();
515 assert_eq!(todo.id, "1");
516 assert_eq!(todo.title, "Test todo");
517 assert_eq!(todo.status, TodoStatus::Pending);
518 assert_eq!(todo.priority, TodoPriority::High);
519 }
520
521 #[test]
522 fn test_todo_empty_title_validation() {
523 let result = Todo::new("1", " ", TodoStatus::Pending, TodoPriority::High);
524 assert!(result.is_err());
525 if let Err(err) = result {
526 assert_eq!(err.code, "INVALID_TITLE");
527 }
528 }
529
530 #[test]
531 fn test_todo_with_description() {
532 let todo = Todo::new("1", "Test", TodoStatus::Pending, TodoPriority::High)
533 .unwrap()
534 .with_description("A test todo");
535 assert_eq!(todo.description, Some("A test todo".to_string()));
536 }
537
538 #[test]
539 fn test_todo_status_parsing() {
540 assert_eq!("pending".parse::<TodoStatus>().unwrap(), TodoStatus::Pending);
541 assert_eq!(
542 "in_progress".parse::<TodoStatus>().unwrap(),
543 TodoStatus::InProgress
544 );
545 assert_eq!(
546 "completed".parse::<TodoStatus>().unwrap(),
547 TodoStatus::Completed
548 );
549 assert_eq!("blocked".parse::<TodoStatus>().unwrap(), TodoStatus::Blocked);
550 assert!("invalid".parse::<TodoStatus>().is_err());
551 }
552
553 #[test]
554 fn test_todo_priority_parsing() {
555 assert_eq!("low".parse::<TodoPriority>().unwrap(), TodoPriority::Low);
556 assert_eq!(
557 "medium".parse::<TodoPriority>().unwrap(),
558 TodoPriority::Medium
559 );
560 assert_eq!("high".parse::<TodoPriority>().unwrap(), TodoPriority::High);
561 assert_eq!(
562 "critical".parse::<TodoPriority>().unwrap(),
563 TodoPriority::Critical
564 );
565 assert!("invalid".parse::<TodoPriority>().is_err());
566 }
567
568 #[test]
569 fn test_todo_storage_load_empty() {
570 let temp_dir = TempDir::new().unwrap();
571 let storage_path = temp_dir.path().join("todos.json");
572 let storage = TodoStorage::new(&storage_path);
573
574 let todos = storage.load_todos().unwrap();
575 assert!(todos.is_empty());
576 }
577
578 #[test]
579 fn test_todo_storage_save_and_load() {
580 let temp_dir = TempDir::new().unwrap();
581 let storage_path = temp_dir.path().join("todos.json");
582 let storage = TodoStorage::new(&storage_path);
583
584 let mut todos = HashMap::new();
586 let todo1 = Todo::new("1", "First todo", TodoStatus::Pending, TodoPriority::High)
587 .unwrap()
588 .with_description("First description");
589 let todo2 = Todo::new("2", "Second todo", TodoStatus::Completed, TodoPriority::Low).unwrap();
590
591 todos.insert(todo1.id.clone(), todo1);
592 todos.insert(todo2.id.clone(), todo2);
593
594 storage.save_todos(&todos).unwrap();
595
596 let loaded = storage.load_todos().unwrap();
598 assert_eq!(loaded.len(), 2);
599 assert!(loaded.contains_key("1"));
600 assert!(loaded.contains_key("2"));
601 }
602
603 #[test]
604 fn test_todo_tools_write_and_read() {
605 let temp_dir = TempDir::new().unwrap();
606 let storage_path = temp_dir.path().join("todos.json");
607 let tools = TodoTools::with_storage_path(&storage_path);
608
609 let todo1 = Todo::new("1", "First", TodoStatus::Pending, TodoPriority::High).unwrap();
611 let todo2 = Todo::new("2", "Second", TodoStatus::InProgress, TodoPriority::Medium).unwrap();
612
613 let write_result = tools
614 .write_todos(TodowriteInput {
615 todos: vec![todo1, todo2],
616 })
617 .unwrap();
618
619 assert_eq!(write_result.created, 2);
620 assert_eq!(write_result.updated, 0);
621
622 let read_result = tools
624 .read_todos(TodoreadInput {
625 status_filter: None,
626 priority_filter: None,
627 })
628 .unwrap();
629
630 assert_eq!(read_result.todos.len(), 2);
631 }
632
633 #[test]
634 fn test_todo_tools_update() {
635 let temp_dir = TempDir::new().unwrap();
636 let storage_path = temp_dir.path().join("todos.json");
637 let tools = TodoTools::with_storage_path(&storage_path);
638
639 let todo1 = Todo::new("1", "First", TodoStatus::Pending, TodoPriority::High).unwrap();
641 tools
642 .write_todos(TodowriteInput {
643 todos: vec![todo1],
644 })
645 .unwrap();
646
647 let updated_todo =
649 Todo::new("1", "First (updated)", TodoStatus::Completed, TodoPriority::Low).unwrap();
650 let write_result = tools
651 .write_todos(TodowriteInput {
652 todos: vec![updated_todo],
653 })
654 .unwrap();
655
656 assert_eq!(write_result.created, 0);
657 assert_eq!(write_result.updated, 1);
658
659 let read_result = tools
661 .read_todos(TodoreadInput {
662 status_filter: None,
663 priority_filter: None,
664 })
665 .unwrap();
666
667 assert_eq!(read_result.todos.len(), 1);
668 assert_eq!(read_result.todos[0].title, "First (updated)");
669 assert_eq!(read_result.todos[0].status, TodoStatus::Completed);
670 }
671
672 #[test]
673 fn test_todo_tools_filter_by_status() {
674 let temp_dir = TempDir::new().unwrap();
675 let storage_path = temp_dir.path().join("todos.json");
676 let tools = TodoTools::with_storage_path(&storage_path);
677
678 let todo1 = Todo::new("1", "Pending", TodoStatus::Pending, TodoPriority::High).unwrap();
680 let todo2 =
681 Todo::new("2", "Completed", TodoStatus::Completed, TodoPriority::Medium).unwrap();
682
683 tools
684 .write_todos(TodowriteInput {
685 todos: vec![todo1, todo2],
686 })
687 .unwrap();
688
689 let read_result = tools
691 .read_todos(TodoreadInput {
692 status_filter: Some(TodoStatus::Completed),
693 priority_filter: None,
694 })
695 .unwrap();
696
697 assert_eq!(read_result.todos.len(), 1);
698 assert_eq!(read_result.todos[0].title, "Completed");
699 }
700
701 #[test]
702 fn test_todo_tools_filter_by_priority() {
703 let temp_dir = TempDir::new().unwrap();
704 let storage_path = temp_dir.path().join("todos.json");
705 let tools = TodoTools::with_storage_path(&storage_path);
706
707 let todo1 = Todo::new("1", "High", TodoStatus::Pending, TodoPriority::High).unwrap();
709 let todo2 = Todo::new("2", "Low", TodoStatus::Pending, TodoPriority::Low).unwrap();
710
711 tools
712 .write_todos(TodowriteInput {
713 todos: vec![todo1, todo2],
714 })
715 .unwrap();
716
717 let read_result = tools
719 .read_todos(TodoreadInput {
720 status_filter: None,
721 priority_filter: Some(TodoPriority::High),
722 })
723 .unwrap();
724
725 assert_eq!(read_result.todos.len(), 1);
726 assert_eq!(read_result.todos[0].title, "High");
727 }
728
729 #[tokio::test]
730 async fn test_todo_write_timeout_enforcement() {
731 let temp_dir = TempDir::new().unwrap();
732 let storage_path = temp_dir.path().join("todos.json");
733 let tools = TodoTools::with_storage_path(&storage_path);
734
735 let todo = Todo::new("1", "Test", TodoStatus::Pending, TodoPriority::High).unwrap();
737 let result = tools
738 .write_todos_with_timeout(TodowriteInput {
739 todos: vec![todo],
740 })
741 .await;
742
743 assert!(result.is_ok());
744 let output = result.unwrap();
745 assert_eq!(output.created, 1);
746 }
747
748 #[tokio::test]
749 async fn test_todo_read_timeout_enforcement() {
750 let temp_dir = TempDir::new().unwrap();
751 let storage_path = temp_dir.path().join("todos.json");
752 let tools = TodoTools::with_storage_path(&storage_path);
753
754 let todo = Todo::new("1", "Test", TodoStatus::Pending, TodoPriority::High).unwrap();
756 tools
757 .write_todos(TodowriteInput {
758 todos: vec![todo],
759 })
760 .unwrap();
761
762 let result = tools
764 .read_todos_with_timeout(TodoreadInput {
765 status_filter: None,
766 priority_filter: None,
767 })
768 .await;
769
770 assert!(result.is_ok());
771 let output = result.unwrap();
772 assert_eq!(output.todos.len(), 1);
773 }
774}