409 lines
13 KiB
Rust
409 lines
13 KiB
Rust
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<Uuid>,
|
|
pub cross_board_depends_on: Vec<Dependency>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
pub created_by: Uuid,
|
|
pub project_id: Option<Uuid>,
|
|
pub parent_todo_id: Option<Uuid>,
|
|
pub subtask_ids: Vec<Uuid>,
|
|
pub affected_files: Vec<String>,
|
|
pub source: ToDoSource,
|
|
pub automation_chain_id: Option<Uuid>,
|
|
pub tags: Vec<String>,
|
|
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<Uuid, Vec<Uuid>>) -> Option<Uuid> {
|
|
let mut visited: HashSet<Uuid> = HashSet::new();
|
|
let mut in_stack: HashSet<Uuid> = HashSet::new();
|
|
|
|
fn dfs(
|
|
node: Uuid,
|
|
deps: &std::collections::HashMap<Uuid, Vec<Uuid>>,
|
|
visited: &mut HashSet<Uuid>,
|
|
in_stack: &mut HashSet<Uuid>,
|
|
) -> Option<Uuid> {
|
|
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<String>,
|
|
description: Option<String>,
|
|
priority: Option<u32>,
|
|
) -> 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());
|
|
}
|
|
}
|