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