use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::enums::{ToDoSource, ToDoStatus}; use crate::errors::ValidationError; use std::collections::HashSet; const MAX_TITLE_LENGTH: usize = 500; const MAX_DESCRIPTION_LENGTH: usize = 10000; const MIN_PRIORITY: u32 = 1; const MAX_PRIORITY: u32 = 1000; const DEFAULT_PRIORITY: u32 = 100; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Dependency { pub todo_id: Uuid, pub board_id: Uuid, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ToDo { pub id: Uuid, pub board_id: Uuid, pub title: String, pub description: String, pub status: ToDoStatus, pub priority: u32, pub depends_on: Vec, pub cross_board_depends_on: Vec, pub created_at: DateTime, pub updated_at: DateTime, pub created_by: Uuid, pub project_id: Option, pub parent_todo_id: Option, pub subtask_ids: Vec, pub affected_files: Vec, pub source: ToDoSource, pub automation_chain_id: Option, pub tags: Vec, pub estimated_tokens: u32, } impl Default for ToDo { fn default() -> Self { let now = Utc::now(); Self { id: Uuid::new_v4(), board_id: Uuid::nil(), title: String::new(), description: String::new(), status: ToDoStatus::default(), priority: DEFAULT_PRIORITY, depends_on: Vec::new(), cross_board_depends_on: Vec::new(), created_at: now, updated_at: now, created_by: Uuid::nil(), project_id: None, parent_todo_id: None, subtask_ids: Vec::new(), affected_files: Vec::new(), source: ToDoSource::User, automation_chain_id: None, tags: Vec::new(), estimated_tokens: 0, } } } impl ToDo { pub fn new(title: String, description: String, created_by: Uuid) -> Self { let now = Utc::now(); Self { id: Uuid::new_v4(), board_id: Uuid::nil(), title: title.trim().to_string(), description, status: ToDoStatus::default(), priority: DEFAULT_PRIORITY, depends_on: Vec::new(), cross_board_depends_on: Vec::new(), created_at: now, updated_at: now, created_by, project_id: None, parent_todo_id: None, subtask_ids: Vec::new(), affected_files: Vec::new(), source: ToDoSource::User, automation_chain_id: None, tags: Vec::new(), estimated_tokens: 0, } } pub fn with_project(mut self, project_id: Uuid) -> Self { self.project_id = Some(project_id); self } pub fn validate(&self) -> Result<(), ValidationError> { if self.title.is_empty() { return Err(ValidationError::EmptyTitle); } if self.title.len() > MAX_TITLE_LENGTH { return Err(ValidationError::TitleTooLong); } if self.description.len() > MAX_DESCRIPTION_LENGTH { return Err(ValidationError::DescriptionTooLong); } if self.priority < MIN_PRIORITY || self.priority > MAX_PRIORITY { return Err(ValidationError::InvalidPriority); } if self.has_circular_dependency() { return Err(ValidationError::CircularDependency(self.id.to_string())); } Ok(()) } fn has_circular_dependency(&self) -> bool { // Direct self-dependency if self.depends_on.contains(&self.id) { return true; } false } /* DFS cycle detection over a full dependency graph. Returns the first todo_id found in a cycle, or None when the graph is acyclic. */ pub fn detect_cycle_in_graph(deps: &std::collections::HashMap>) -> Option { let mut visited: HashSet = HashSet::new(); let mut in_stack: HashSet = HashSet::new(); fn dfs( node: Uuid, deps: &std::collections::HashMap>, visited: &mut HashSet, in_stack: &mut HashSet, ) -> Option { if in_stack.contains(&node) { return Some(node); } if visited.contains(&node) { return None; } visited.insert(node); in_stack.insert(node); if let Some(children) = deps.get(&node) { for child in children { if let Some(cycle_node) = dfs(*child, deps, visited, in_stack) { return Some(cycle_node); } } } in_stack.remove(&node); None } for node in deps.keys() { if !visited.contains(node) { if let Some(cycle_node) = dfs(*node, deps, &mut visited, &mut in_stack) { return Some(cycle_node); } } } None } pub fn set_status(&mut self, status: ToDoStatus) -> Result<(), ValidationError> { let valid_transition = match (&self.status, &status) { (ToDoStatus::Pending, ToDoStatus::InProgress) => true, (ToDoStatus::Pending, ToDoStatus::Blocked) => true, (ToDoStatus::Pending, ToDoStatus::ReadyForAgent) => true, (ToDoStatus::InProgress, ToDoStatus::Completed) => true, (ToDoStatus::InProgress, ToDoStatus::Blocked) => true, (ToDoStatus::InProgress, ToDoStatus::ReadyForAgent) => true, (ToDoStatus::Blocked, ToDoStatus::InProgress) => true, (ToDoStatus::Blocked, ToDoStatus::Pending) => true, (ToDoStatus::Blocked, ToDoStatus::ReadyForAgent) => true, (ToDoStatus::Completed, ToDoStatus::InProgress) => true, (ToDoStatus::ReadyForAgent, ToDoStatus::InProgress) => true, (ToDoStatus::ReadyForAgent, ToDoStatus::Pending) => true, (ToDoStatus::ReadyForAgent, ToDoStatus::Blocked) => true, (ToDoStatus::InProgress, ToDoStatus::Delegated) => true, (ToDoStatus::InProgress, ToDoStatus::Failed) => true, (ToDoStatus::InProgress, ToDoStatus::PendingApproval) => true, (ToDoStatus::Delegated, ToDoStatus::InProgress) => true, (ToDoStatus::Delegated, ToDoStatus::Completed) => true, (ToDoStatus::Delegated, ToDoStatus::Blocked) => true, (ToDoStatus::Failed, ToDoStatus::Pending) => true, (ToDoStatus::PendingApproval, ToDoStatus::InProgress) => true, (ToDoStatus::PendingApproval, ToDoStatus::Blocked) => true, _ => self.status == status, }; if !valid_transition { return Err(ValidationError::InvalidStatusTransition { from: self.status.to_string(), to: status.to_string(), }); } self.status = status; self.updated_at = Utc::now(); Ok(()) } pub fn update( &mut self, title: Option, description: Option, priority: Option, ) -> Result<(), ValidationError> { if let Some(t) = title { let trimmed = t.trim().to_string(); if trimmed.is_empty() { return Err(ValidationError::EmptyTitle); } if trimmed.len() > MAX_TITLE_LENGTH { return Err(ValidationError::TitleTooLong); } self.title = trimmed; } if let Some(d) = description { if d.len() > MAX_DESCRIPTION_LENGTH { return Err(ValidationError::DescriptionTooLong); } self.description = d; } if let Some(p) = priority { if p < MIN_PRIORITY || p > MAX_PRIORITY { return Err(ValidationError::InvalidPriority); } self.priority = p; } self.updated_at = Utc::now(); Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_todo_new() { let todo = ToDo::new( "Test Title".to_string(), "Test Description".to_string(), Uuid::new_v4(), ); assert!(!todo.id.is_nil()); assert_eq!(todo.title, "Test Title"); assert_eq!(todo.description, "Test Description"); assert_eq!(todo.status, ToDoStatus::Pending); assert_eq!(todo.priority, DEFAULT_PRIORITY); } #[test] fn test_todo_validate_empty_title() { let mut todo = ToDo::default(); todo.title = "".to_string(); assert!(matches!(todo.validate(), Err(ValidationError::EmptyTitle))); } #[test] fn test_todo_validate_title_too_long() { let mut todo = ToDo::default(); todo.title = "a".repeat(MAX_TITLE_LENGTH + 1); assert!(matches!( todo.validate(), Err(ValidationError::TitleTooLong) )); } #[test] fn test_todo_validate_priority() { let mut todo = ToDo::new("Test".to_string(), "desc".to_string(), Uuid::new_v4()); todo.priority = 0; assert!(matches!( todo.validate(), Err(ValidationError::InvalidPriority) )); let mut todo = ToDo::new("Test".to_string(), "desc".to_string(), Uuid::new_v4()); todo.priority = 1001; assert!(matches!( todo.validate(), Err(ValidationError::InvalidPriority) )); let mut todo = ToDo::new("Test".to_string(), "desc".to_string(), Uuid::new_v4()); todo.priority = 500; assert!(todo.validate().is_ok()); } #[test] fn test_todo_status_transition() { let mut todo = ToDo::default(); assert!(todo.set_status(ToDoStatus::InProgress).is_ok()); assert!(matches!( todo.set_status(ToDoStatus::Pending), Err(ValidationError::InvalidStatusTransition { .. }) )); } #[test] fn test_todo_default_values() { let todo = ToDo::default(); assert_eq!(todo.status, ToDoStatus::Pending); assert_eq!(todo.priority, DEFAULT_PRIORITY); assert!(todo.depends_on.is_empty()); assert!(!todo.id.is_nil()); } #[test] fn test_todo_serialization_roundtrip() { let original = ToDo::new( "Test Title".to_string(), "Test Description".to_string(), Uuid::new_v4(), ); let serialized = serde_json::to_string(&original).unwrap(); let deserialized: ToDo = serde_json::from_str(&serialized).unwrap(); assert_eq!(original.id, deserialized.id); assert_eq!(original.title, deserialized.title); assert_eq!(original.description, deserialized.description); assert_eq!(original.status, deserialized.status); assert_eq!(original.priority, deserialized.priority); } #[test] fn test_todo_update_title() { let mut todo = ToDo::default(); let result = todo.update(Some("New Title".to_string()), None, None); assert!(result.is_ok()); assert_eq!(todo.title, "New Title"); } #[test] fn test_todo_update_empty_title() { let mut todo = ToDo::default(); let result = todo.update(Some("".to_string()), None, None); assert!(matches!(result, Err(ValidationError::EmptyTitle))); } #[test] fn test_todo_update_priority() { let mut todo = ToDo::default(); let result = todo.update(None, None, Some(50)); assert!(result.is_ok()); assert_eq!(todo.priority, 50); } #[test] fn test_todo_update_invalid_priority() { let mut todo = ToDo::default(); let result = todo.update(None, None, Some(0)); assert!(matches!(result, Err(ValidationError::InvalidPriority))); } #[test] fn test_has_circular_dependency_self_reference() { let mut todo = ToDo::default(); todo.depends_on = vec![todo.id]; assert!(todo.has_circular_dependency()); } #[test] fn test_has_circular_dependency_none() { let todo = ToDo::default(); assert!(!todo.has_circular_dependency()); } #[test] fn test_detect_cycle_in_graph_simple_cycle() { let id_a = Uuid::new_v4(); let id_b = Uuid::new_v4(); let mut deps = std::collections::HashMap::new(); deps.insert(id_a, vec![id_b]); deps.insert(id_b, vec![id_a]); assert!(ToDo::detect_cycle_in_graph(&deps).is_some()); } #[test] fn test_detect_cycle_in_graph_no_cycle() { let id_a = Uuid::new_v4(); let id_b = Uuid::new_v4(); let mut deps = std::collections::HashMap::new(); deps.insert(id_a, vec![id_b]); deps.insert(id_b, vec![]); assert!(ToDo::detect_cycle_in_graph(&deps).is_none()); } }