1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
4#[serde(rename_all = "kebab-case")]
5pub enum TaskStatus {
6 #[default]
7 Pending,
8 InProgress,
9 Done,
10 Review,
11 Blocked,
12 Deferred,
13 Cancelled,
14 Expanded, }
16
17impl TaskStatus {
18 pub fn as_str(&self) -> &'static str {
19 match self {
20 TaskStatus::Pending => "pending",
21 TaskStatus::InProgress => "in-progress",
22 TaskStatus::Done => "done",
23 TaskStatus::Review => "review",
24 TaskStatus::Blocked => "blocked",
25 TaskStatus::Deferred => "deferred",
26 TaskStatus::Cancelled => "cancelled",
27 TaskStatus::Expanded => "expanded",
28 }
29 }
30
31 #[allow(clippy::should_implement_trait)]
32 pub fn from_str(s: &str) -> Option<Self> {
33 match s {
34 "pending" => Some(TaskStatus::Pending),
35 "in-progress" => Some(TaskStatus::InProgress),
36 "done" => Some(TaskStatus::Done),
37 "review" => Some(TaskStatus::Review),
38 "blocked" => Some(TaskStatus::Blocked),
39 "deferred" => Some(TaskStatus::Deferred),
40 "cancelled" => Some(TaskStatus::Cancelled),
41 "expanded" => Some(TaskStatus::Expanded),
42 _ => None,
43 }
44 }
45
46 pub fn all() -> Vec<&'static str> {
47 vec![
48 "pending",
49 "in-progress",
50 "done",
51 "review",
52 "blocked",
53 "deferred",
54 "cancelled",
55 "expanded",
56 ]
57 }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
61#[serde(rename_all = "lowercase")]
62pub enum Priority {
63 Critical,
64 High,
65 #[default]
66 Medium,
67 Low,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct Task {
72 pub id: String,
73 pub title: String,
74 pub description: String,
75
76 #[serde(default)]
77 pub status: TaskStatus,
78
79 #[serde(default)]
80 pub complexity: u32,
81
82 #[serde(default)]
83 pub priority: Priority,
84
85 #[serde(default)]
86 pub dependencies: Vec<String>,
87
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub parent_id: Option<String>,
91
92 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub subtasks: Vec<String>,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub details: Option<String>,
97
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub test_strategy: Option<String>,
100
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub created_at: Option<String>,
103
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub updated_at: Option<String>,
106
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub assigned_to: Option<String>,
110
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub locked_by: Option<String>,
113
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub locked_at: Option<String>,
116}
117
118impl Task {
119 const MAX_TITLE_LENGTH: usize = 200;
121 const MAX_DESCRIPTION_LENGTH: usize = 5000;
122 const VALID_FIBONACCI_NUMBERS: &'static [u32] = &[0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89];
123
124 pub const ID_SEPARATOR: char = ':';
126
127 pub fn new(id: String, title: String, description: String) -> Self {
128 let now = chrono::Utc::now().to_rfc3339();
129 Task {
130 id,
131 title,
132 description,
133 status: TaskStatus::Pending,
134 complexity: 0,
135 priority: Priority::Medium,
136 dependencies: Vec::new(),
137 parent_id: None,
138 subtasks: Vec::new(),
139 details: None,
140 test_strategy: None,
141 created_at: Some(now.clone()),
142 updated_at: Some(now),
143 assigned_to: None,
144 locked_by: None,
145 locked_at: None,
146 }
147 }
148
149 pub fn parse_id(id: &str) -> Option<(&str, &str)> {
153 id.split_once(Self::ID_SEPARATOR)
154 }
155
156 pub fn make_id(epic_tag: &str, local_id: &str) -> String {
158 format!("{}{}{}", epic_tag, Self::ID_SEPARATOR, local_id)
159 }
160
161 pub fn local_id(&self) -> &str {
163 Self::parse_id(&self.id)
164 .map(|(_, local)| local)
165 .unwrap_or(&self.id)
166 }
167
168 pub fn epic_tag(&self) -> Option<&str> {
170 Self::parse_id(&self.id).map(|(tag, _)| tag)
171 }
172
173 pub fn is_subtask(&self) -> bool {
175 self.parent_id.is_some()
176 }
177
178 pub fn is_expanded(&self) -> bool {
180 self.status == TaskStatus::Expanded || !self.subtasks.is_empty()
181 }
182
183 pub fn validate_id(id: &str) -> Result<(), String> {
186 if id.is_empty() {
187 return Err("Task ID cannot be empty".to_string());
188 }
189
190 if id.len() > 100 {
191 return Err("Task ID too long (max 100 characters)".to_string());
192 }
193
194 let valid_chars = id
196 .chars()
197 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ':' || c == '.');
198
199 if !valid_chars {
200 return Err(
201 "Task ID can only contain alphanumeric characters, hyphens, underscores, colons, and dots"
202 .to_string(),
203 );
204 }
205
206 Ok(())
207 }
208
209 pub fn validate_title(title: &str) -> Result<(), String> {
211 if title.trim().is_empty() {
212 return Err("Task title cannot be empty".to_string());
213 }
214
215 if title.len() > Self::MAX_TITLE_LENGTH {
216 return Err(format!(
217 "Task title too long (max {} characters)",
218 Self::MAX_TITLE_LENGTH
219 ));
220 }
221
222 Ok(())
223 }
224
225 pub fn validate_description(description: &str) -> Result<(), String> {
227 if description.len() > Self::MAX_DESCRIPTION_LENGTH {
228 return Err(format!(
229 "Task description too long (max {} characters)",
230 Self::MAX_DESCRIPTION_LENGTH
231 ));
232 }
233
234 Ok(())
235 }
236
237 pub fn validate_complexity(complexity: u32) -> Result<(), String> {
239 if !Self::VALID_FIBONACCI_NUMBERS.contains(&complexity) {
240 return Err(format!(
241 "Complexity must be a Fibonacci number: {:?}",
242 Self::VALID_FIBONACCI_NUMBERS
243 ));
244 }
245
246 Ok(())
247 }
248
249 pub fn sanitize_text(text: &str) -> String {
251 text.replace('<', "<")
252 .replace('>', ">")
253 .replace('"', """)
254 .replace('\'', "'")
255 }
256
257 pub fn validate(&self) -> Result<(), Vec<String>> {
259 let mut errors = Vec::new();
260
261 if let Err(e) = Self::validate_id(&self.id) {
262 errors.push(e);
263 }
264
265 if let Err(e) = Self::validate_title(&self.title) {
266 errors.push(e);
267 }
268
269 if let Err(e) = Self::validate_description(&self.description) {
270 errors.push(e);
271 }
272
273 if self.complexity > 0 {
274 if let Err(e) = Self::validate_complexity(self.complexity) {
275 errors.push(e);
276 }
277 }
278
279 if errors.is_empty() {
280 Ok(())
281 } else {
282 Err(errors)
283 }
284 }
285
286 pub fn set_status(&mut self, status: TaskStatus) {
287 self.status = status;
288 self.updated_at = Some(chrono::Utc::now().to_rfc3339());
289 }
290
291 pub fn update(&mut self) {
292 self.updated_at = Some(chrono::Utc::now().to_rfc3339());
293 }
294
295 pub fn has_dependencies_met(&self, all_tasks: &[Task]) -> bool {
296 self.dependencies.iter().all(|dep_id| {
297 all_tasks
298 .iter()
299 .find(|t| &t.id == dep_id)
300 .map(|t| t.status == TaskStatus::Done)
301 .unwrap_or(false)
302 })
303 }
304
305 pub fn has_dependencies_met_refs(&self, all_tasks: &[&Task]) -> bool {
308 self.dependencies.iter().all(|dep_id| {
309 all_tasks
310 .iter()
311 .find(|t| &t.id == dep_id)
312 .map(|t| t.status == TaskStatus::Done)
313 .unwrap_or(false)
314 })
315 }
316
317 pub fn needs_expansion(&self) -> bool {
321 self.complexity >= 3 && !self.is_expanded() && !self.is_subtask()
322 }
323
324 pub fn recommended_subtasks(&self) -> usize {
332 Self::recommended_subtasks_for_complexity(self.complexity)
333 }
334
335 pub fn recommended_subtasks_for_complexity(complexity: u32) -> usize {
337 match complexity {
338 0..=2 => 2,
339 3 => 3,
340 5 => 4,
341 8 => 5,
342 13 => 6,
343 _ => 8, }
345 }
346
347 pub fn assign(&mut self, assignee: &str) {
349 self.assigned_to = Some(assignee.to_string());
350 self.update();
351 }
352
353 pub fn claim(&mut self, assignee: &str) -> Result<(), String> {
354 if let Some(ref locked_by) = self.locked_by {
355 if locked_by != assignee {
356 return Err(format!("Task is locked by {}", locked_by));
357 }
358 }
359
360 self.assigned_to = Some(assignee.to_string());
361 self.locked_by = Some(assignee.to_string());
362 self.locked_at = Some(chrono::Utc::now().to_rfc3339());
363 self.update();
364 Ok(())
365 }
366
367 pub fn release(&mut self) {
368 self.locked_by = None;
369 self.locked_at = None;
370 self.update();
371 }
372
373 pub fn is_locked(&self) -> bool {
374 self.locked_by.is_some()
375 }
376
377 pub fn is_locked_by(&self, assignee: &str) -> bool {
378 self.locked_by
379 .as_ref()
380 .map(|s| s == assignee)
381 .unwrap_or(false)
382 }
383
384 pub fn is_assigned_to(&self, assignee: &str) -> bool {
385 self.assigned_to
386 .as_ref()
387 .map(|s| s == assignee)
388 .unwrap_or(false)
389 }
390
391 pub fn lock_age_hours(&self) -> Option<f64> {
392 self.locked_at.as_ref().and_then(|locked_at| {
393 chrono::DateTime::parse_from_rfc3339(locked_at)
394 .ok()
395 .map(|dt| {
396 let now = chrono::Utc::now();
397 let duration = now.signed_duration_since(dt);
398 duration.num_seconds() as f64 / 3600.0
399 })
400 })
401 }
402
403 pub fn is_stale_lock(&self, hours_threshold: f64) -> bool {
404 self.lock_age_hours()
405 .map(|hours| hours > hours_threshold)
406 .unwrap_or(false)
407 }
408
409 pub fn would_create_cycle(&self, new_dep_id: &str, all_tasks: &[Task]) -> Result<(), String> {
412 if self.id == new_dep_id {
413 return Err(format!("Self-reference: {} -> {}", self.id, new_dep_id));
414 }
415
416 let mut visited = std::collections::HashSet::new();
417 let mut path = Vec::new();
418
419 Self::detect_cycle_recursive(new_dep_id, &self.id, all_tasks, &mut visited, &mut path)
420 }
421
422 fn detect_cycle_recursive(
423 current_id: &str,
424 target_id: &str,
425 all_tasks: &[Task],
426 visited: &mut std::collections::HashSet<String>,
427 path: &mut Vec<String>,
428 ) -> Result<(), String> {
429 if current_id == target_id {
430 path.push(current_id.to_string());
431 return Err(format!("Circular dependency: {}", path.join(" -> ")));
432 }
433
434 if visited.contains(current_id) {
435 return Ok(());
436 }
437
438 visited.insert(current_id.to_string());
439 path.push(current_id.to_string());
440
441 if let Some(task) = all_tasks.iter().find(|t| t.id == current_id) {
442 for dep_id in &task.dependencies {
443 Self::detect_cycle_recursive(dep_id, target_id, all_tasks, visited, path)?;
444 }
445 }
446
447 path.pop();
448 Ok(())
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn test_task_creation() {
458 let task = Task::new(
459 "TASK-1".to_string(),
460 "Test Task".to_string(),
461 "Description".to_string(),
462 );
463
464 assert_eq!(task.id, "TASK-1");
465 assert_eq!(task.title, "Test Task");
466 assert_eq!(task.description, "Description");
467 assert_eq!(task.status, TaskStatus::Pending);
468 assert_eq!(task.complexity, 0);
469 assert_eq!(task.priority, Priority::Medium);
470 assert!(task.dependencies.is_empty());
471 assert!(task.created_at.is_some());
472 assert!(task.updated_at.is_some());
473 assert!(task.assigned_to.is_none());
474 assert!(task.locked_by.is_none());
475 assert!(task.locked_at.is_none());
476 }
477
478 #[test]
479 fn test_status_conversion() {
480 assert_eq!(TaskStatus::Pending.as_str(), "pending");
481 assert_eq!(TaskStatus::InProgress.as_str(), "in-progress");
482 assert_eq!(TaskStatus::Done.as_str(), "done");
483 assert_eq!(TaskStatus::Review.as_str(), "review");
484 assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
485 assert_eq!(TaskStatus::Deferred.as_str(), "deferred");
486 assert_eq!(TaskStatus::Cancelled.as_str(), "cancelled");
487 }
488
489 #[test]
490 fn test_status_from_string() {
491 assert_eq!(TaskStatus::from_str("pending"), Some(TaskStatus::Pending));
492 assert_eq!(
493 TaskStatus::from_str("in-progress"),
494 Some(TaskStatus::InProgress)
495 );
496 assert_eq!(TaskStatus::from_str("done"), Some(TaskStatus::Done));
497 assert_eq!(TaskStatus::from_str("invalid"), None);
498 }
499
500 #[test]
501 fn test_set_status_updates_timestamp() {
502 let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
503 let initial_updated = task.updated_at.clone();
504
505 std::thread::sleep(std::time::Duration::from_millis(10));
506 task.set_status(TaskStatus::InProgress);
507
508 assert_eq!(task.status, TaskStatus::InProgress);
509 assert!(task.updated_at > initial_updated);
510 }
511
512 #[test]
513 fn test_task_assignment() {
514 let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
515
516 task.assign("alice");
517 assert_eq!(task.assigned_to, Some("alice".to_string()));
518 assert!(task.is_assigned_to("alice"));
519 assert!(!task.is_assigned_to("bob"));
520 }
521
522 #[test]
523 fn test_task_claim_success() {
524 let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
525
526 let result = task.claim("alice");
527 assert!(result.is_ok());
528 assert_eq!(task.assigned_to, Some("alice".to_string()));
529 assert_eq!(task.locked_by, Some("alice".to_string()));
530 assert!(task.locked_at.is_some());
531 assert!(task.is_locked());
532 assert!(task.is_locked_by("alice"));
533 }
534
535 #[test]
536 fn test_task_claim_already_locked_by_same_user() {
537 let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
538
539 task.claim("alice").unwrap();
540 let result = task.claim("alice");
541 assert!(result.is_ok()); }
543
544 #[test]
545 fn test_task_claim_already_locked_by_different_user() {
546 let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
547
548 task.claim("alice").unwrap();
549 let result = task.claim("bob");
550 assert!(result.is_err());
551 assert_eq!(result.unwrap_err(), "Task is locked by alice");
552 }
553
554 #[test]
555 fn test_task_release() {
556 let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
557
558 task.claim("alice").unwrap();
559 assert!(task.is_locked());
560
561 task.release();
562 assert!(!task.is_locked());
563 assert_eq!(task.locked_by, None);
564 assert_eq!(task.locked_at, None);
565 assert_eq!(task.assigned_to, Some("alice".to_string())); }
567
568 #[test]
569 fn test_lock_age_calculation() {
570 let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
571
572 task.claim("alice").unwrap();
573
574 let age = task.lock_age_hours();
575 assert!(age.is_some());
576 assert!(age.unwrap() < 0.001); }
578
579 #[test]
580 fn test_stale_lock_detection() {
581 let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
582
583 task.claim("alice").unwrap();
584
585 assert!(!task.is_stale_lock(24.0));
587
588 let two_days_ago = chrono::Utc::now() - chrono::Duration::hours(48);
590 task.locked_at = Some(two_days_ago.to_rfc3339());
591
592 assert!(task.is_stale_lock(24.0));
593 assert!(!task.is_stale_lock(72.0));
594 }
595
596 #[test]
597 fn test_has_dependencies_met_all_done() {
598 let mut task = Task::new("TASK-3".to_string(), "Test".to_string(), "Desc".to_string());
599 task.dependencies = vec!["TASK-1".to_string(), "TASK-2".to_string()];
600
601 let mut task1 = Task::new(
602 "TASK-1".to_string(),
603 "Dep 1".to_string(),
604 "Desc".to_string(),
605 );
606 task1.set_status(TaskStatus::Done);
607
608 let mut task2 = Task::new(
609 "TASK-2".to_string(),
610 "Dep 2".to_string(),
611 "Desc".to_string(),
612 );
613 task2.set_status(TaskStatus::Done);
614
615 let all_tasks = vec![task1, task2];
616 assert!(task.has_dependencies_met(&all_tasks));
617 }
618
619 #[test]
620 fn test_has_dependencies_met_some_pending() {
621 let mut task = Task::new("TASK-3".to_string(), "Test".to_string(), "Desc".to_string());
622 task.dependencies = vec!["TASK-1".to_string(), "TASK-2".to_string()];
623
624 let mut task1 = Task::new(
625 "TASK-1".to_string(),
626 "Dep 1".to_string(),
627 "Desc".to_string(),
628 );
629 task1.set_status(TaskStatus::Done);
630
631 let task2 = Task::new(
632 "TASK-2".to_string(),
633 "Dep 2".to_string(),
634 "Desc".to_string(),
635 );
636 let all_tasks = vec![task1, task2];
639 assert!(!task.has_dependencies_met(&all_tasks));
640 }
641
642 #[test]
643 fn test_has_dependencies_met_missing_dependency() {
644 let mut task = Task::new("TASK-3".to_string(), "Test".to_string(), "Desc".to_string());
645 task.dependencies = vec!["TASK-1".to_string(), "TASK-MISSING".to_string()];
646
647 let mut task1 = Task::new(
648 "TASK-1".to_string(),
649 "Dep 1".to_string(),
650 "Desc".to_string(),
651 );
652 task1.set_status(TaskStatus::Done);
653
654 let all_tasks = vec![task1];
655 assert!(!task.has_dependencies_met(&all_tasks));
656 }
657
658 #[test]
659 fn test_needs_expansion() {
660 let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
661
662 task.complexity = 1;
664 assert!(!task.needs_expansion());
665
666 task.complexity = 2;
667 assert!(!task.needs_expansion());
668
669 task.complexity = 3;
671 assert!(task.needs_expansion());
672
673 task.complexity = 8;
674 assert!(task.needs_expansion());
675
676 task.complexity = 13;
677 assert!(task.needs_expansion());
678
679 task.complexity = 21;
680 assert!(task.needs_expansion());
681
682 task.status = TaskStatus::Expanded;
684 assert!(!task.needs_expansion());
685
686 task.status = TaskStatus::Pending;
688 task.parent_id = Some("parent:1".to_string());
689 assert!(!task.needs_expansion()); task.parent_id = None;
693 task.subtasks = vec!["TASK-1.1".to_string()];
694 assert!(!task.needs_expansion()); }
696
697 #[test]
698 fn test_task_serialization() {
699 let task = Task::new(
700 "TASK-1".to_string(),
701 "Test Task".to_string(),
702 "Description".to_string(),
703 );
704
705 let json = serde_json::to_string(&task).unwrap();
706 let deserialized: Task = serde_json::from_str(&json).unwrap();
707
708 assert_eq!(task.id, deserialized.id);
709 assert_eq!(task.title, deserialized.title);
710 assert_eq!(task.description, deserialized.description);
711 }
712
713 #[test]
714 fn test_task_serialization_with_optional_fields() {
715 let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
716 task.details = Some("Detailed info".to_string());
717 task.test_strategy = Some("Test plan".to_string());
718 task.claim("alice").unwrap();
719
720 let json = serde_json::to_string(&task).unwrap();
721 let deserialized: Task = serde_json::from_str(&json).unwrap();
722
723 assert_eq!(task.details, deserialized.details);
724 assert_eq!(task.test_strategy, deserialized.test_strategy);
725 assert_eq!(task.locked_by, deserialized.locked_by);
726 assert_eq!(task.locked_at, deserialized.locked_at);
727 }
728
729 #[test]
730 fn test_priority_default() {
731 let default_priority = Priority::default();
732 assert_eq!(default_priority, Priority::Medium);
733 }
734
735 #[test]
736 fn test_status_all() {
737 let all_statuses = TaskStatus::all();
738 assert_eq!(all_statuses.len(), 8);
739 assert!(all_statuses.contains(&"pending"));
740 assert!(all_statuses.contains(&"in-progress"));
741 assert!(all_statuses.contains(&"done"));
742 assert!(all_statuses.contains(&"review"));
743 assert!(all_statuses.contains(&"blocked"));
744 assert!(all_statuses.contains(&"deferred"));
745 assert!(all_statuses.contains(&"cancelled"));
746 assert!(all_statuses.contains(&"expanded"));
747 }
748
749 #[test]
750 fn test_circular_dependency_self_reference() {
751 let task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
752 let all_tasks = vec![task.clone()];
753
754 let result = task.would_create_cycle("TASK-1", &all_tasks);
755 assert!(result.is_err());
756 assert!(result.unwrap_err().contains("Self-reference"));
757 }
758
759 #[test]
760 fn test_circular_dependency_direct_cycle() {
761 let mut task1 = Task::new(
762 "TASK-1".to_string(),
763 "Task 1".to_string(),
764 "Desc".to_string(),
765 );
766 task1.dependencies = vec!["TASK-2".to_string()];
767
768 let task2 = Task::new(
769 "TASK-2".to_string(),
770 "Task 2".to_string(),
771 "Desc".to_string(),
772 );
773
774 let all_tasks = vec![task1.clone(), task2.clone()];
775
776 let result = task2.would_create_cycle("TASK-1", &all_tasks);
778 assert!(result.is_err());
779 assert!(result.unwrap_err().contains("Circular dependency"));
780 }
781
782 #[test]
783 fn test_circular_dependency_indirect_cycle() {
784 let mut task1 = Task::new(
785 "TASK-1".to_string(),
786 "Task 1".to_string(),
787 "Desc".to_string(),
788 );
789 task1.dependencies = vec!["TASK-2".to_string()];
790
791 let mut task2 = Task::new(
792 "TASK-2".to_string(),
793 "Task 2".to_string(),
794 "Desc".to_string(),
795 );
796 task2.dependencies = vec!["TASK-3".to_string()];
797
798 let task3 = Task::new(
799 "TASK-3".to_string(),
800 "Task 3".to_string(),
801 "Desc".to_string(),
802 );
803
804 let all_tasks = vec![task1.clone(), task2, task3.clone()];
805
806 let result = task3.would_create_cycle("TASK-1", &all_tasks);
809 assert!(result.is_err());
810 assert!(result.unwrap_err().contains("Circular dependency"));
811 }
812
813 #[test]
814 fn test_circular_dependency_no_cycle() {
815 let mut task1 = Task::new(
816 "TASK-1".to_string(),
817 "Task 1".to_string(),
818 "Desc".to_string(),
819 );
820 task1.dependencies = vec!["TASK-3".to_string()];
821
822 let task2 = Task::new(
823 "TASK-2".to_string(),
824 "Task 2".to_string(),
825 "Desc".to_string(),
826 );
827
828 let task3 = Task::new(
829 "TASK-3".to_string(),
830 "Task 3".to_string(),
831 "Desc".to_string(),
832 );
833
834 let all_tasks = vec![task1.clone(), task2.clone(), task3];
835
836 let result = task1.would_create_cycle("TASK-2", &all_tasks);
838 assert!(result.is_ok());
839 }
840
841 #[test]
842 fn test_circular_dependency_complex_graph() {
843 let mut task1 = Task::new(
844 "TASK-1".to_string(),
845 "Task 1".to_string(),
846 "Desc".to_string(),
847 );
848 task1.dependencies = vec!["TASK-2".to_string(), "TASK-3".to_string()];
849
850 let mut task2 = Task::new(
851 "TASK-2".to_string(),
852 "Task 2".to_string(),
853 "Desc".to_string(),
854 );
855 task2.dependencies = vec!["TASK-4".to_string()];
856
857 let mut task3 = Task::new(
858 "TASK-3".to_string(),
859 "Task 3".to_string(),
860 "Desc".to_string(),
861 );
862 task3.dependencies = vec!["TASK-4".to_string()];
863
864 let task4 = Task::new(
865 "TASK-4".to_string(),
866 "Task 4".to_string(),
867 "Desc".to_string(),
868 );
869
870 let all_tasks = vec![task1.clone(), task2, task3, task4.clone()];
871
872 let result = task4.would_create_cycle("TASK-1", &all_tasks);
874 assert!(result.is_err());
875 assert!(result.unwrap_err().contains("Circular dependency"));
876 }
877
878 #[test]
880 fn test_validate_id_success() {
881 assert!(Task::validate_id("TASK-123").is_ok());
882 assert!(Task::validate_id("task_456").is_ok());
883 assert!(Task::validate_id("Feature-789").is_ok());
884 assert!(Task::validate_id("phase1:10").is_ok());
886 assert!(Task::validate_id("phase1:10.1").is_ok());
887 assert!(Task::validate_id("my-epic:subtask-1.2.3").is_ok());
888 }
889
890 #[test]
891 fn test_validate_id_empty() {
892 let result = Task::validate_id("");
893 assert!(result.is_err());
894 assert_eq!(result.unwrap_err(), "Task ID cannot be empty");
895 }
896
897 #[test]
898 fn test_validate_id_too_long() {
899 let long_id = "A".repeat(101);
900 let result = Task::validate_id(&long_id);
901 assert!(result.is_err());
902 assert!(result.unwrap_err().contains("too long"));
903 }
904
905 #[test]
906 fn test_validate_id_invalid_characters() {
907 assert!(Task::validate_id("TASK@123").is_err());
908 assert!(Task::validate_id("TASK 123").is_err());
909 assert!(Task::validate_id("TASK#123").is_err());
910 assert!(Task::validate_id("TASK.123").is_ok()); assert!(Task::validate_id("epic:TASK-1").is_ok()); }
914
915 #[test]
916 fn test_validate_title_success() {
917 assert!(Task::validate_title("Valid title").is_ok());
918 assert!(Task::validate_title("A").is_ok());
919 }
920
921 #[test]
922 fn test_validate_title_empty() {
923 let result = Task::validate_title("");
924 assert!(result.is_err());
925 assert_eq!(result.unwrap_err(), "Task title cannot be empty");
926
927 let result = Task::validate_title(" ");
928 assert!(result.is_err());
929 assert_eq!(result.unwrap_err(), "Task title cannot be empty");
930 }
931
932 #[test]
933 fn test_validate_title_too_long() {
934 let long_title = "A".repeat(201);
935 let result = Task::validate_title(&long_title);
936 assert!(result.is_err());
937 assert!(result.unwrap_err().contains("too long"));
938 }
939
940 #[test]
941 fn test_validate_description_success() {
942 assert!(Task::validate_description("Valid description").is_ok());
943 assert!(Task::validate_description("").is_ok());
944 }
945
946 #[test]
947 fn test_validate_description_too_long() {
948 let long_desc = "A".repeat(5001);
949 let result = Task::validate_description(&long_desc);
950 assert!(result.is_err());
951 assert!(result.unwrap_err().contains("too long"));
952 }
953
954 #[test]
955 fn test_validate_complexity_success() {
956 assert!(Task::validate_complexity(0).is_ok());
957 assert!(Task::validate_complexity(1).is_ok());
958 assert!(Task::validate_complexity(2).is_ok());
959 assert!(Task::validate_complexity(3).is_ok());
960 assert!(Task::validate_complexity(5).is_ok());
961 assert!(Task::validate_complexity(8).is_ok());
962 assert!(Task::validate_complexity(13).is_ok());
963 assert!(Task::validate_complexity(21).is_ok());
964 }
965
966 #[test]
967 fn test_validate_complexity_invalid() {
968 assert!(Task::validate_complexity(4).is_err());
969 assert!(Task::validate_complexity(6).is_err());
970 assert!(Task::validate_complexity(7).is_err());
971 assert!(Task::validate_complexity(100).is_err());
972 }
973
974 #[test]
975 fn test_sanitize_text() {
976 assert_eq!(
977 Task::sanitize_text("<script>alert('xss')</script>"),
978 "<script>alert('xss')</script>"
979 );
980 assert_eq!(Task::sanitize_text("Normal text"), "Normal text");
981 assert_eq!(
982 Task::sanitize_text("<div>Content</div>"),
983 "<div>Content</div>"
984 );
985 }
986
987 #[test]
988 fn test_validate_success() {
989 let task = Task::new(
990 "TASK-1".to_string(),
991 "Valid title".to_string(),
992 "Valid description".to_string(),
993 );
994 assert!(task.validate().is_ok());
995 }
996
997 #[test]
998 fn test_validate_multiple_errors() {
999 let mut task = Task::new("TASK@INVALID".to_string(), "".to_string(), "A".repeat(5001));
1000 task.complexity = 100; let result = task.validate();
1003 assert!(result.is_err());
1004 let errors = result.unwrap_err();
1005 assert_eq!(errors.len(), 4);
1006 assert!(errors.iter().any(|e| e.contains("ID")));
1007 assert!(errors.iter().any(|e| e.contains("title")));
1008 assert!(errors.iter().any(|e| e.contains("description")));
1009 assert!(errors.iter().any(|e| e.contains("Complexity")));
1010 }
1011
1012 #[test]
1014 fn test_cross_tag_dependency_met() {
1015 let mut task_a = Task::new(
1016 "auth:1".to_string(),
1017 "Auth task".to_string(),
1018 "Desc".to_string(),
1019 );
1020 task_a.set_status(TaskStatus::Done);
1021
1022 let mut task_b = Task::new(
1023 "api:1".to_string(),
1024 "API task".to_string(),
1025 "Desc".to_string(),
1026 );
1027 task_b.dependencies = vec!["auth:1".to_string()];
1028
1029 let all_tasks = vec![&task_a, &task_b];
1030 assert!(task_b.has_dependencies_met_refs(&all_tasks));
1031 }
1032
1033 #[test]
1034 fn test_cross_tag_dependency_not_met() {
1035 let task_a = Task::new(
1036 "auth:1".to_string(),
1037 "Auth task".to_string(),
1038 "Desc".to_string(),
1039 );
1040 let mut task_b = Task::new(
1043 "api:1".to_string(),
1044 "API task".to_string(),
1045 "Desc".to_string(),
1046 );
1047 task_b.dependencies = vec!["auth:1".to_string()];
1048
1049 let all_tasks = vec![&task_a, &task_b];
1050 assert!(!task_b.has_dependencies_met_refs(&all_tasks));
1051 }
1052
1053 #[test]
1054 fn test_local_dependency_still_works_with_refs() {
1055 let mut task_a = Task::new("1".to_string(), "First".to_string(), "Desc".to_string());
1056 task_a.set_status(TaskStatus::Done);
1057
1058 let mut task_b = Task::new("2".to_string(), "Second".to_string(), "Desc".to_string());
1059 task_b.dependencies = vec!["1".to_string()];
1060
1061 let all_tasks = vec![&task_a, &task_b];
1062 assert!(task_b.has_dependencies_met_refs(&all_tasks));
1063 }
1064
1065 #[test]
1066 fn test_has_dependencies_met_refs_missing_dependency() {
1067 let mut task = Task::new("api:1".to_string(), "Test".to_string(), "Desc".to_string());
1068 task.dependencies = vec!["auth:1".to_string(), "auth:MISSING".to_string()];
1069
1070 let mut dep1 = Task::new(
1071 "auth:1".to_string(),
1072 "Dep 1".to_string(),
1073 "Desc".to_string(),
1074 );
1075 dep1.set_status(TaskStatus::Done);
1076
1077 let all_tasks = vec![&dep1];
1078 assert!(!task.has_dependencies_met_refs(&all_tasks));
1079 }
1080}