scud/models/
task.rs

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, // Task has been broken into subtasks
15}
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    // Parent-child relationship for expanded tasks
89    #[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    // Parallel execution support
108    #[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    // Validation constants
120    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    /// ID separator for namespaced IDs (epic:local_id)
125    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    /// Parse a task ID into (epic_tag, local_id) parts
150    /// e.g., "phase1:10.1" -> Some(("phase1", "10.1"))
151    /// e.g., "10.1" -> None (legacy format)
152    pub fn parse_id(id: &str) -> Option<(&str, &str)> {
153        id.split_once(Self::ID_SEPARATOR)
154    }
155
156    /// Create a namespaced task ID
157    pub fn make_id(epic_tag: &str, local_id: &str) -> String {
158        format!("{}{}{}", epic_tag, Self::ID_SEPARATOR, local_id)
159    }
160
161    /// Get the local ID part (without epic prefix)
162    pub fn local_id(&self) -> &str {
163        Self::parse_id(&self.id)
164            .map(|(_, local)| local)
165            .unwrap_or(&self.id)
166    }
167
168    /// Get the epic tag from a namespaced ID
169    pub fn epic_tag(&self) -> Option<&str> {
170        Self::parse_id(&self.id).map(|(tag, _)| tag)
171    }
172
173    /// Check if this is a subtask (has parent)
174    pub fn is_subtask(&self) -> bool {
175        self.parent_id.is_some()
176    }
177
178    /// Check if this task has been expanded into subtasks
179    pub fn is_expanded(&self) -> bool {
180        self.status == TaskStatus::Expanded || !self.subtasks.is_empty()
181    }
182
183    /// Validate task ID - must contain only alphanumeric characters, hyphens, underscores,
184    /// colons (for namespacing), and dots (for subtask IDs)
185    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        // Allow alphanumeric, hyphen, underscore, colon (namespacing), and dot (subtask IDs)
195        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    /// Validate title - must not be empty and within length limit
210    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    /// Validate description - within length limit
226    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    /// Validate complexity - must be a Fibonacci number
238    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    /// Sanitize text by removing potentially dangerous HTML/script tags
250    pub fn sanitize_text(text: &str) -> String {
251        text.replace('<', "&lt;")
252            .replace('>', "&gt;")
253            .replace('"', "&quot;")
254            .replace('\'', "&#x27;")
255    }
256
257    /// Comprehensive validation of all task fields
258    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    /// Check if all dependencies are met, searching across provided task references
306    /// Supports cross-tag dependencies when passed tasks from all phases
307    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    /// Returns whether this task should be expanded into subtasks
318    /// All tasks with complexity >= 3 can benefit from expansion
319    /// Subtasks and already-expanded tasks don't need expansion
320    pub fn needs_expansion(&self) -> bool {
321        self.complexity >= 3 && !self.is_expanded() && !self.is_subtask()
322    }
323
324    /// Returns the recommended number of subtasks based on complexity
325    /// Complexity 1-2: 2 subtasks
326    /// Complexity 3: 2-3 subtasks
327    /// Complexity 5: 3-4 subtasks
328    /// Complexity 8: 4-5 subtasks
329    /// Complexity 13: 5-6 subtasks
330    /// Complexity 21+: 6-8 subtasks
331    pub fn recommended_subtasks(&self) -> usize {
332        Self::recommended_subtasks_for_complexity(self.complexity)
333    }
334
335    /// Static version for use when we only have complexity value
336    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, // 21+
344        }
345    }
346
347    // Assignment and locking methods
348    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    /// Check if adding a dependency would create a circular reference
410    /// Returns Err with the cycle path if circular dependency detected
411    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()); // Same user can re-claim
542    }
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())); // Assignment persists
566    }
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); // Should be very recent (< 1 minute)
577    }
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        // Not stale immediately
586        assert!(!task.is_stale_lock(24.0));
587
588        // Simulate old lock by setting locked_at to 48 hours ago
589        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        // task2 is pending
637
638        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        // Complexity < 3 should not need expansion
663        task.complexity = 1;
664        assert!(!task.needs_expansion());
665
666        task.complexity = 2;
667        assert!(!task.needs_expansion());
668
669        // Complexity >= 3 should need expansion
670        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        // Already expanded tasks (with Expanded status) should not need expansion
683        task.status = TaskStatus::Expanded;
684        assert!(!task.needs_expansion());
685
686        // Reset status and test subtask case
687        task.status = TaskStatus::Pending;
688        task.parent_id = Some("parent:1".to_string());
689        assert!(!task.needs_expansion()); // Subtasks don't need expansion
690
691        // Reset and test tasks with subtasks
692        task.parent_id = None;
693        task.subtasks = vec!["TASK-1.1".to_string()];
694        assert!(!task.needs_expansion()); // Already has subtasks
695    }
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        // Trying to add TASK-1 as dependency of TASK-2 would create cycle: TASK-2 -> TASK-1 -> TASK-2
777        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        // Trying to add TASK-1 as dependency of TASK-3 would create cycle:
807        // TASK-3 -> TASK-1 -> TASK-2 -> TASK-3
808        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        // Adding TASK-2 as dependency of TASK-1 is fine (no cycle)
837        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        // Adding TASK-1 as dependency of TASK-4 would create a cycle
873        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    // Validation tests
879    #[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        // Namespaced IDs
885        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        // Note: dot and colon are now valid for namespaced IDs
911        assert!(Task::validate_id("TASK.123").is_ok()); // Valid for subtask IDs like "10.1"
912        assert!(Task::validate_id("epic:TASK-1").is_ok()); // Valid namespaced ID
913    }
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            "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;"
979        );
980        assert_eq!(Task::sanitize_text("Normal text"), "Normal text");
981        assert_eq!(
982            Task::sanitize_text("<div>Content</div>"),
983            "&lt;div&gt;Content&lt;/div&gt;"
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; // Invalid Fibonacci number
1001
1002        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    // Cross-tag dependency tests
1013    #[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        // task_a still pending
1041
1042        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}