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    // Assignment tracking (informational only, no locking)
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub assigned_to: Option<String>,
110}
111
112impl Task {
113    // Validation constants
114    const MAX_TITLE_LENGTH: usize = 200;
115    const MAX_DESCRIPTION_LENGTH: usize = 5000;
116    const VALID_FIBONACCI_NUMBERS: &'static [u32] = &[0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89];
117
118    /// ID separator for namespaced IDs (epic:local_id)
119    pub const ID_SEPARATOR: char = ':';
120
121    pub fn new(id: String, title: String, description: String) -> Self {
122        let now = chrono::Utc::now().to_rfc3339();
123        Task {
124            id,
125            title,
126            description,
127            status: TaskStatus::Pending,
128            complexity: 0,
129            priority: Priority::Medium,
130            dependencies: Vec::new(),
131            parent_id: None,
132            subtasks: Vec::new(),
133            details: None,
134            test_strategy: None,
135            created_at: Some(now.clone()),
136            updated_at: Some(now),
137            assigned_to: None,
138        }
139    }
140
141    /// Parse a task ID into (epic_tag, local_id) parts
142    /// e.g., "phase1:10.1" -> Some(("phase1", "10.1"))
143    /// e.g., "10.1" -> None (legacy format)
144    pub fn parse_id(id: &str) -> Option<(&str, &str)> {
145        id.split_once(Self::ID_SEPARATOR)
146    }
147
148    /// Create a namespaced task ID
149    pub fn make_id(epic_tag: &str, local_id: &str) -> String {
150        format!("{}{}{}", epic_tag, Self::ID_SEPARATOR, local_id)
151    }
152
153    /// Get the local ID part (without epic prefix)
154    pub fn local_id(&self) -> &str {
155        Self::parse_id(&self.id)
156            .map(|(_, local)| local)
157            .unwrap_or(&self.id)
158    }
159
160    /// Get the epic tag from a namespaced ID
161    pub fn epic_tag(&self) -> Option<&str> {
162        Self::parse_id(&self.id).map(|(tag, _)| tag)
163    }
164
165    /// Check if this is a subtask (has parent)
166    pub fn is_subtask(&self) -> bool {
167        self.parent_id.is_some()
168    }
169
170    /// Check if this task has been expanded into subtasks
171    pub fn is_expanded(&self) -> bool {
172        self.status == TaskStatus::Expanded || !self.subtasks.is_empty()
173    }
174
175    /// Validate task ID - must contain only alphanumeric characters, hyphens, underscores,
176    /// colons (for namespacing), and dots (for subtask IDs)
177    pub fn validate_id(id: &str) -> Result<(), String> {
178        if id.is_empty() {
179            return Err("Task ID cannot be empty".to_string());
180        }
181
182        if id.len() > 100 {
183            return Err("Task ID too long (max 100 characters)".to_string());
184        }
185
186        // Allow alphanumeric, hyphen, underscore, colon (namespacing), and dot (subtask IDs)
187        let valid_chars = id
188            .chars()
189            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ':' || c == '.');
190
191        if !valid_chars {
192            return Err(
193                "Task ID can only contain alphanumeric characters, hyphens, underscores, colons, and dots"
194                    .to_string(),
195            );
196        }
197
198        Ok(())
199    }
200
201    /// Validate title - must not be empty and within length limit
202    pub fn validate_title(title: &str) -> Result<(), String> {
203        if title.trim().is_empty() {
204            return Err("Task title cannot be empty".to_string());
205        }
206
207        if title.len() > Self::MAX_TITLE_LENGTH {
208            return Err(format!(
209                "Task title too long (max {} characters)",
210                Self::MAX_TITLE_LENGTH
211            ));
212        }
213
214        Ok(())
215    }
216
217    /// Validate description - within length limit
218    pub fn validate_description(description: &str) -> Result<(), String> {
219        if description.len() > Self::MAX_DESCRIPTION_LENGTH {
220            return Err(format!(
221                "Task description too long (max {} characters)",
222                Self::MAX_DESCRIPTION_LENGTH
223            ));
224        }
225
226        Ok(())
227    }
228
229    /// Validate complexity - must be a Fibonacci number
230    pub fn validate_complexity(complexity: u32) -> Result<(), String> {
231        if !Self::VALID_FIBONACCI_NUMBERS.contains(&complexity) {
232            return Err(format!(
233                "Complexity must be a Fibonacci number: {:?}",
234                Self::VALID_FIBONACCI_NUMBERS
235            ));
236        }
237
238        Ok(())
239    }
240
241    /// Sanitize text by removing potentially dangerous HTML/script tags
242    pub fn sanitize_text(text: &str) -> String {
243        text.replace('<', "&lt;")
244            .replace('>', "&gt;")
245            .replace('"', "&quot;")
246            .replace('\'', "&#x27;")
247    }
248
249    /// Comprehensive validation of all task fields
250    pub fn validate(&self) -> Result<(), Vec<String>> {
251        let mut errors = Vec::new();
252
253        if let Err(e) = Self::validate_id(&self.id) {
254            errors.push(e);
255        }
256
257        if let Err(e) = Self::validate_title(&self.title) {
258            errors.push(e);
259        }
260
261        if let Err(e) = Self::validate_description(&self.description) {
262            errors.push(e);
263        }
264
265        if self.complexity > 0 {
266            if let Err(e) = Self::validate_complexity(self.complexity) {
267                errors.push(e);
268            }
269        }
270
271        if errors.is_empty() {
272            Ok(())
273        } else {
274            Err(errors)
275        }
276    }
277
278    pub fn set_status(&mut self, status: TaskStatus) {
279        self.status = status;
280        self.updated_at = Some(chrono::Utc::now().to_rfc3339());
281    }
282
283    pub fn update(&mut self) {
284        self.updated_at = Some(chrono::Utc::now().to_rfc3339());
285    }
286
287    pub fn has_dependencies_met(&self, all_tasks: &[Task]) -> bool {
288        self.dependencies.iter().all(|dep_id| {
289            all_tasks
290                .iter()
291                .find(|t| &t.id == dep_id)
292                .map(|t| t.status == TaskStatus::Done)
293                .unwrap_or(false)
294        })
295    }
296
297    /// Check if all dependencies are met, searching across provided task references
298    /// Supports cross-tag dependencies when passed tasks from all phases
299    pub fn has_dependencies_met_refs(&self, all_tasks: &[&Task]) -> bool {
300        self.dependencies.iter().all(|dep_id| {
301            all_tasks
302                .iter()
303                .find(|t| &t.id == dep_id)
304                .map(|t| t.status == TaskStatus::Done)
305                .unwrap_or(false)
306        })
307    }
308
309    /// Returns whether this task should be expanded into subtasks
310    /// All tasks with complexity >= 3 can benefit from expansion
311    /// Subtasks and already-expanded tasks don't need expansion
312    pub fn needs_expansion(&self) -> bool {
313        self.complexity >= 3 && !self.is_expanded() && !self.is_subtask()
314    }
315
316    /// Returns the recommended number of subtasks based on complexity
317    /// Complexity 1-2: 2 subtasks
318    /// Complexity 3: 2-3 subtasks
319    /// Complexity 5: 3-4 subtasks
320    /// Complexity 8: 4-5 subtasks
321    /// Complexity 13: 5-6 subtasks
322    /// Complexity 21+: 6-8 subtasks
323    pub fn recommended_subtasks(&self) -> usize {
324        Self::recommended_subtasks_for_complexity(self.complexity)
325    }
326
327    /// Static version for use when we only have complexity value
328    pub fn recommended_subtasks_for_complexity(complexity: u32) -> usize {
329        match complexity {
330            0..=2 => 2,
331            3 => 3,
332            5 => 4,
333            8 => 5,
334            13 => 6,
335            _ => 8, // 21+
336        }
337    }
338
339    // Assignment methods (informational only, no locking)
340    pub fn assign(&mut self, assignee: &str) {
341        self.assigned_to = Some(assignee.to_string());
342        self.update();
343    }
344
345    pub fn is_assigned_to(&self, assignee: &str) -> bool {
346        self.assigned_to
347            .as_ref()
348            .map(|s| s == assignee)
349            .unwrap_or(false)
350    }
351
352    /// Check if adding a dependency would create a circular reference
353    /// Returns Err with the cycle path if circular dependency detected
354    pub fn would_create_cycle(&self, new_dep_id: &str, all_tasks: &[Task]) -> Result<(), String> {
355        if self.id == new_dep_id {
356            return Err(format!("Self-reference: {} -> {}", self.id, new_dep_id));
357        }
358
359        let mut visited = std::collections::HashSet::new();
360        let mut path = Vec::new();
361
362        Self::detect_cycle_recursive(new_dep_id, &self.id, all_tasks, &mut visited, &mut path)
363    }
364
365    fn detect_cycle_recursive(
366        current_id: &str,
367        target_id: &str,
368        all_tasks: &[Task],
369        visited: &mut std::collections::HashSet<String>,
370        path: &mut Vec<String>,
371    ) -> Result<(), String> {
372        if current_id == target_id {
373            path.push(current_id.to_string());
374            return Err(format!("Circular dependency: {}", path.join(" -> ")));
375        }
376
377        if visited.contains(current_id) {
378            return Ok(());
379        }
380
381        visited.insert(current_id.to_string());
382        path.push(current_id.to_string());
383
384        if let Some(task) = all_tasks.iter().find(|t| t.id == current_id) {
385            for dep_id in &task.dependencies {
386                Self::detect_cycle_recursive(dep_id, target_id, all_tasks, visited, path)?;
387            }
388        }
389
390        path.pop();
391        Ok(())
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn test_task_creation() {
401        let task = Task::new(
402            "TASK-1".to_string(),
403            "Test Task".to_string(),
404            "Description".to_string(),
405        );
406
407        assert_eq!(task.id, "TASK-1");
408        assert_eq!(task.title, "Test Task");
409        assert_eq!(task.description, "Description");
410        assert_eq!(task.status, TaskStatus::Pending);
411        assert_eq!(task.complexity, 0);
412        assert_eq!(task.priority, Priority::Medium);
413        assert!(task.dependencies.is_empty());
414        assert!(task.created_at.is_some());
415        assert!(task.updated_at.is_some());
416        assert!(task.assigned_to.is_none());
417    }
418
419    #[test]
420    fn test_status_conversion() {
421        assert_eq!(TaskStatus::Pending.as_str(), "pending");
422        assert_eq!(TaskStatus::InProgress.as_str(), "in-progress");
423        assert_eq!(TaskStatus::Done.as_str(), "done");
424        assert_eq!(TaskStatus::Review.as_str(), "review");
425        assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
426        assert_eq!(TaskStatus::Deferred.as_str(), "deferred");
427        assert_eq!(TaskStatus::Cancelled.as_str(), "cancelled");
428    }
429
430    #[test]
431    fn test_status_from_string() {
432        assert_eq!(TaskStatus::from_str("pending"), Some(TaskStatus::Pending));
433        assert_eq!(
434            TaskStatus::from_str("in-progress"),
435            Some(TaskStatus::InProgress)
436        );
437        assert_eq!(TaskStatus::from_str("done"), Some(TaskStatus::Done));
438        assert_eq!(TaskStatus::from_str("invalid"), None);
439    }
440
441    #[test]
442    fn test_set_status_updates_timestamp() {
443        let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
444        let initial_updated = task.updated_at.clone();
445
446        std::thread::sleep(std::time::Duration::from_millis(10));
447        task.set_status(TaskStatus::InProgress);
448
449        assert_eq!(task.status, TaskStatus::InProgress);
450        assert!(task.updated_at > initial_updated);
451    }
452
453    #[test]
454    fn test_task_assignment() {
455        let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
456
457        task.assign("alice");
458        assert_eq!(task.assigned_to, Some("alice".to_string()));
459        assert!(task.is_assigned_to("alice"));
460        assert!(!task.is_assigned_to("bob"));
461    }
462
463    #[test]
464    fn test_has_dependencies_met_all_done() {
465        let mut task = Task::new("TASK-3".to_string(), "Test".to_string(), "Desc".to_string());
466        task.dependencies = vec!["TASK-1".to_string(), "TASK-2".to_string()];
467
468        let mut task1 = Task::new(
469            "TASK-1".to_string(),
470            "Dep 1".to_string(),
471            "Desc".to_string(),
472        );
473        task1.set_status(TaskStatus::Done);
474
475        let mut task2 = Task::new(
476            "TASK-2".to_string(),
477            "Dep 2".to_string(),
478            "Desc".to_string(),
479        );
480        task2.set_status(TaskStatus::Done);
481
482        let all_tasks = vec![task1, task2];
483        assert!(task.has_dependencies_met(&all_tasks));
484    }
485
486    #[test]
487    fn test_has_dependencies_met_some_pending() {
488        let mut task = Task::new("TASK-3".to_string(), "Test".to_string(), "Desc".to_string());
489        task.dependencies = vec!["TASK-1".to_string(), "TASK-2".to_string()];
490
491        let mut task1 = Task::new(
492            "TASK-1".to_string(),
493            "Dep 1".to_string(),
494            "Desc".to_string(),
495        );
496        task1.set_status(TaskStatus::Done);
497
498        let task2 = Task::new(
499            "TASK-2".to_string(),
500            "Dep 2".to_string(),
501            "Desc".to_string(),
502        );
503        // task2 is pending
504
505        let all_tasks = vec![task1, task2];
506        assert!(!task.has_dependencies_met(&all_tasks));
507    }
508
509    #[test]
510    fn test_has_dependencies_met_missing_dependency() {
511        let mut task = Task::new("TASK-3".to_string(), "Test".to_string(), "Desc".to_string());
512        task.dependencies = vec!["TASK-1".to_string(), "TASK-MISSING".to_string()];
513
514        let mut task1 = Task::new(
515            "TASK-1".to_string(),
516            "Dep 1".to_string(),
517            "Desc".to_string(),
518        );
519        task1.set_status(TaskStatus::Done);
520
521        let all_tasks = vec![task1];
522        assert!(!task.has_dependencies_met(&all_tasks));
523    }
524
525    #[test]
526    fn test_needs_expansion() {
527        let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
528
529        // Complexity < 3 should not need expansion
530        task.complexity = 1;
531        assert!(!task.needs_expansion());
532
533        task.complexity = 2;
534        assert!(!task.needs_expansion());
535
536        // Complexity >= 3 should need expansion
537        task.complexity = 3;
538        assert!(task.needs_expansion());
539
540        task.complexity = 8;
541        assert!(task.needs_expansion());
542
543        task.complexity = 13;
544        assert!(task.needs_expansion());
545
546        task.complexity = 21;
547        assert!(task.needs_expansion());
548
549        // Already expanded tasks (with Expanded status) should not need expansion
550        task.status = TaskStatus::Expanded;
551        assert!(!task.needs_expansion());
552
553        // Reset status and test subtask case
554        task.status = TaskStatus::Pending;
555        task.parent_id = Some("parent:1".to_string());
556        assert!(!task.needs_expansion()); // Subtasks don't need expansion
557
558        // Reset and test tasks with subtasks
559        task.parent_id = None;
560        task.subtasks = vec!["TASK-1.1".to_string()];
561        assert!(!task.needs_expansion()); // Already has subtasks
562    }
563
564    #[test]
565    fn test_task_serialization() {
566        let task = Task::new(
567            "TASK-1".to_string(),
568            "Test Task".to_string(),
569            "Description".to_string(),
570        );
571
572        let json = serde_json::to_string(&task).unwrap();
573        let deserialized: Task = serde_json::from_str(&json).unwrap();
574
575        assert_eq!(task.id, deserialized.id);
576        assert_eq!(task.title, deserialized.title);
577        assert_eq!(task.description, deserialized.description);
578    }
579
580    #[test]
581    fn test_task_serialization_with_optional_fields() {
582        let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
583        task.details = Some("Detailed info".to_string());
584        task.test_strategy = Some("Test plan".to_string());
585        task.assign("alice");
586
587        let json = serde_json::to_string(&task).unwrap();
588        let deserialized: Task = serde_json::from_str(&json).unwrap();
589
590        assert_eq!(task.details, deserialized.details);
591        assert_eq!(task.test_strategy, deserialized.test_strategy);
592        assert_eq!(task.assigned_to, deserialized.assigned_to);
593    }
594
595    #[test]
596    fn test_priority_default() {
597        let default_priority = Priority::default();
598        assert_eq!(default_priority, Priority::Medium);
599    }
600
601    #[test]
602    fn test_status_all() {
603        let all_statuses = TaskStatus::all();
604        assert_eq!(all_statuses.len(), 8);
605        assert!(all_statuses.contains(&"pending"));
606        assert!(all_statuses.contains(&"in-progress"));
607        assert!(all_statuses.contains(&"done"));
608        assert!(all_statuses.contains(&"review"));
609        assert!(all_statuses.contains(&"blocked"));
610        assert!(all_statuses.contains(&"deferred"));
611        assert!(all_statuses.contains(&"cancelled"));
612        assert!(all_statuses.contains(&"expanded"));
613    }
614
615    #[test]
616    fn test_circular_dependency_self_reference() {
617        let task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
618        let all_tasks = vec![task.clone()];
619
620        let result = task.would_create_cycle("TASK-1", &all_tasks);
621        assert!(result.is_err());
622        assert!(result.unwrap_err().contains("Self-reference"));
623    }
624
625    #[test]
626    fn test_circular_dependency_direct_cycle() {
627        let mut task1 = Task::new(
628            "TASK-1".to_string(),
629            "Task 1".to_string(),
630            "Desc".to_string(),
631        );
632        task1.dependencies = vec!["TASK-2".to_string()];
633
634        let task2 = Task::new(
635            "TASK-2".to_string(),
636            "Task 2".to_string(),
637            "Desc".to_string(),
638        );
639
640        let all_tasks = vec![task1.clone(), task2.clone()];
641
642        // Trying to add TASK-1 as dependency of TASK-2 would create cycle: TASK-2 -> TASK-1 -> TASK-2
643        let result = task2.would_create_cycle("TASK-1", &all_tasks);
644        assert!(result.is_err());
645        assert!(result.unwrap_err().contains("Circular dependency"));
646    }
647
648    #[test]
649    fn test_circular_dependency_indirect_cycle() {
650        let mut task1 = Task::new(
651            "TASK-1".to_string(),
652            "Task 1".to_string(),
653            "Desc".to_string(),
654        );
655        task1.dependencies = vec!["TASK-2".to_string()];
656
657        let mut task2 = Task::new(
658            "TASK-2".to_string(),
659            "Task 2".to_string(),
660            "Desc".to_string(),
661        );
662        task2.dependencies = vec!["TASK-3".to_string()];
663
664        let task3 = Task::new(
665            "TASK-3".to_string(),
666            "Task 3".to_string(),
667            "Desc".to_string(),
668        );
669
670        let all_tasks = vec![task1.clone(), task2, task3.clone()];
671
672        // Trying to add TASK-1 as dependency of TASK-3 would create cycle:
673        // TASK-3 -> TASK-1 -> TASK-2 -> TASK-3
674        let result = task3.would_create_cycle("TASK-1", &all_tasks);
675        assert!(result.is_err());
676        assert!(result.unwrap_err().contains("Circular dependency"));
677    }
678
679    #[test]
680    fn test_circular_dependency_no_cycle() {
681        let mut task1 = Task::new(
682            "TASK-1".to_string(),
683            "Task 1".to_string(),
684            "Desc".to_string(),
685        );
686        task1.dependencies = vec!["TASK-3".to_string()];
687
688        let task2 = Task::new(
689            "TASK-2".to_string(),
690            "Task 2".to_string(),
691            "Desc".to_string(),
692        );
693
694        let task3 = Task::new(
695            "TASK-3".to_string(),
696            "Task 3".to_string(),
697            "Desc".to_string(),
698        );
699
700        let all_tasks = vec![task1.clone(), task2.clone(), task3];
701
702        // Adding TASK-2 as dependency of TASK-1 is fine (no cycle)
703        let result = task1.would_create_cycle("TASK-2", &all_tasks);
704        assert!(result.is_ok());
705    }
706
707    #[test]
708    fn test_circular_dependency_complex_graph() {
709        let mut task1 = Task::new(
710            "TASK-1".to_string(),
711            "Task 1".to_string(),
712            "Desc".to_string(),
713        );
714        task1.dependencies = vec!["TASK-2".to_string(), "TASK-3".to_string()];
715
716        let mut task2 = Task::new(
717            "TASK-2".to_string(),
718            "Task 2".to_string(),
719            "Desc".to_string(),
720        );
721        task2.dependencies = vec!["TASK-4".to_string()];
722
723        let mut task3 = Task::new(
724            "TASK-3".to_string(),
725            "Task 3".to_string(),
726            "Desc".to_string(),
727        );
728        task3.dependencies = vec!["TASK-4".to_string()];
729
730        let task4 = Task::new(
731            "TASK-4".to_string(),
732            "Task 4".to_string(),
733            "Desc".to_string(),
734        );
735
736        let all_tasks = vec![task1.clone(), task2, task3, task4.clone()];
737
738        // Adding TASK-1 as dependency of TASK-4 would create a cycle
739        let result = task4.would_create_cycle("TASK-1", &all_tasks);
740        assert!(result.is_err());
741        assert!(result.unwrap_err().contains("Circular dependency"));
742    }
743
744    // Validation tests
745    #[test]
746    fn test_validate_id_success() {
747        assert!(Task::validate_id("TASK-123").is_ok());
748        assert!(Task::validate_id("task_456").is_ok());
749        assert!(Task::validate_id("Feature-789").is_ok());
750        // Namespaced IDs
751        assert!(Task::validate_id("phase1:10").is_ok());
752        assert!(Task::validate_id("phase1:10.1").is_ok());
753        assert!(Task::validate_id("my-epic:subtask-1.2.3").is_ok());
754    }
755
756    #[test]
757    fn test_validate_id_empty() {
758        let result = Task::validate_id("");
759        assert!(result.is_err());
760        assert_eq!(result.unwrap_err(), "Task ID cannot be empty");
761    }
762
763    #[test]
764    fn test_validate_id_too_long() {
765        let long_id = "A".repeat(101);
766        let result = Task::validate_id(&long_id);
767        assert!(result.is_err());
768        assert!(result.unwrap_err().contains("too long"));
769    }
770
771    #[test]
772    fn test_validate_id_invalid_characters() {
773        assert!(Task::validate_id("TASK@123").is_err());
774        assert!(Task::validate_id("TASK 123").is_err());
775        assert!(Task::validate_id("TASK#123").is_err());
776        // Note: dot and colon are now valid for namespaced IDs
777        assert!(Task::validate_id("TASK.123").is_ok()); // Valid for subtask IDs like "10.1"
778        assert!(Task::validate_id("epic:TASK-1").is_ok()); // Valid namespaced ID
779    }
780
781    #[test]
782    fn test_validate_title_success() {
783        assert!(Task::validate_title("Valid title").is_ok());
784        assert!(Task::validate_title("A").is_ok());
785    }
786
787    #[test]
788    fn test_validate_title_empty() {
789        let result = Task::validate_title("");
790        assert!(result.is_err());
791        assert_eq!(result.unwrap_err(), "Task title cannot be empty");
792
793        let result = Task::validate_title("   ");
794        assert!(result.is_err());
795        assert_eq!(result.unwrap_err(), "Task title cannot be empty");
796    }
797
798    #[test]
799    fn test_validate_title_too_long() {
800        let long_title = "A".repeat(201);
801        let result = Task::validate_title(&long_title);
802        assert!(result.is_err());
803        assert!(result.unwrap_err().contains("too long"));
804    }
805
806    #[test]
807    fn test_validate_description_success() {
808        assert!(Task::validate_description("Valid description").is_ok());
809        assert!(Task::validate_description("").is_ok());
810    }
811
812    #[test]
813    fn test_validate_description_too_long() {
814        let long_desc = "A".repeat(5001);
815        let result = Task::validate_description(&long_desc);
816        assert!(result.is_err());
817        assert!(result.unwrap_err().contains("too long"));
818    }
819
820    #[test]
821    fn test_validate_complexity_success() {
822        assert!(Task::validate_complexity(0).is_ok());
823        assert!(Task::validate_complexity(1).is_ok());
824        assert!(Task::validate_complexity(2).is_ok());
825        assert!(Task::validate_complexity(3).is_ok());
826        assert!(Task::validate_complexity(5).is_ok());
827        assert!(Task::validate_complexity(8).is_ok());
828        assert!(Task::validate_complexity(13).is_ok());
829        assert!(Task::validate_complexity(21).is_ok());
830    }
831
832    #[test]
833    fn test_validate_complexity_invalid() {
834        assert!(Task::validate_complexity(4).is_err());
835        assert!(Task::validate_complexity(6).is_err());
836        assert!(Task::validate_complexity(7).is_err());
837        assert!(Task::validate_complexity(100).is_err());
838    }
839
840    #[test]
841    fn test_sanitize_text() {
842        assert_eq!(
843            Task::sanitize_text("<script>alert('xss')</script>"),
844            "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;"
845        );
846        assert_eq!(Task::sanitize_text("Normal text"), "Normal text");
847        assert_eq!(
848            Task::sanitize_text("<div>Content</div>"),
849            "&lt;div&gt;Content&lt;/div&gt;"
850        );
851    }
852
853    #[test]
854    fn test_validate_success() {
855        let task = Task::new(
856            "TASK-1".to_string(),
857            "Valid title".to_string(),
858            "Valid description".to_string(),
859        );
860        assert!(task.validate().is_ok());
861    }
862
863    #[test]
864    fn test_validate_multiple_errors() {
865        let mut task = Task::new("TASK@INVALID".to_string(), "".to_string(), "A".repeat(5001));
866        task.complexity = 100; // Invalid Fibonacci number
867
868        let result = task.validate();
869        assert!(result.is_err());
870        let errors = result.unwrap_err();
871        assert_eq!(errors.len(), 4);
872        assert!(errors.iter().any(|e| e.contains("ID")));
873        assert!(errors.iter().any(|e| e.contains("title")));
874        assert!(errors.iter().any(|e| e.contains("description")));
875        assert!(errors.iter().any(|e| e.contains("Complexity")));
876    }
877
878    // Cross-tag dependency tests
879    #[test]
880    fn test_cross_tag_dependency_met() {
881        let mut task_a = Task::new(
882            "auth:1".to_string(),
883            "Auth task".to_string(),
884            "Desc".to_string(),
885        );
886        task_a.set_status(TaskStatus::Done);
887
888        let mut task_b = Task::new(
889            "api:1".to_string(),
890            "API task".to_string(),
891            "Desc".to_string(),
892        );
893        task_b.dependencies = vec!["auth:1".to_string()];
894
895        let all_tasks = vec![&task_a, &task_b];
896        assert!(task_b.has_dependencies_met_refs(&all_tasks));
897    }
898
899    #[test]
900    fn test_cross_tag_dependency_not_met() {
901        let task_a = Task::new(
902            "auth:1".to_string(),
903            "Auth task".to_string(),
904            "Desc".to_string(),
905        );
906        // task_a still pending
907
908        let mut task_b = Task::new(
909            "api:1".to_string(),
910            "API task".to_string(),
911            "Desc".to_string(),
912        );
913        task_b.dependencies = vec!["auth:1".to_string()];
914
915        let all_tasks = vec![&task_a, &task_b];
916        assert!(!task_b.has_dependencies_met_refs(&all_tasks));
917    }
918
919    #[test]
920    fn test_local_dependency_still_works_with_refs() {
921        let mut task_a = Task::new("1".to_string(), "First".to_string(), "Desc".to_string());
922        task_a.set_status(TaskStatus::Done);
923
924        let mut task_b = Task::new("2".to_string(), "Second".to_string(), "Desc".to_string());
925        task_b.dependencies = vec!["1".to_string()];
926
927        let all_tasks = vec![&task_a, &task_b];
928        assert!(task_b.has_dependencies_met_refs(&all_tasks));
929    }
930
931    #[test]
932    fn test_has_dependencies_met_refs_missing_dependency() {
933        let mut task = Task::new("api:1".to_string(), "Test".to_string(), "Desc".to_string());
934        task.dependencies = vec!["auth:1".to_string(), "auth:MISSING".to_string()];
935
936        let mut dep1 = Task::new(
937            "auth:1".to_string(),
938            "Dep 1".to_string(),
939            "Desc".to_string(),
940        );
941        dep1.set_status(TaskStatus::Done);
942
943        let all_tasks = vec![&dep1];
944        assert!(!task.has_dependencies_met_refs(&all_tasks));
945    }
946}