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    /// Get effective dependencies including inherited parent dependencies
298    /// Subtasks inherit their parent's dependencies (including cross-tag deps)
299    pub fn get_effective_dependencies(&self, all_tasks: &[&Task]) -> Vec<String> {
300        let mut deps = self.dependencies.clone();
301
302        // If this is a subtask, inherit parent's dependencies
303        if let Some(ref parent_id) = self.parent_id {
304            if let Some(parent) = all_tasks.iter().find(|t| &t.id == parent_id) {
305                // Recursively get parent's effective dependencies
306                let parent_deps = parent.get_effective_dependencies(all_tasks);
307                deps.extend(parent_deps);
308            }
309        }
310
311        // Deduplicate
312        deps.sort();
313        deps.dedup();
314        deps
315    }
316
317    /// Check if all dependencies are met, searching across provided task references
318    /// Supports cross-tag dependencies when passed tasks from all phases
319    /// Subtasks inherit parent dependencies via get_effective_dependencies
320    pub fn has_dependencies_met_refs(&self, all_tasks: &[&Task]) -> bool {
321        self.get_effective_dependencies(all_tasks)
322            .iter()
323            .all(|dep_id| {
324                all_tasks
325                    .iter()
326                    .find(|t| &t.id == dep_id)
327                    .map(|t| t.status == TaskStatus::Done)
328                    .unwrap_or(false)
329            })
330    }
331
332    /// Returns whether this task should be expanded into subtasks
333    /// Only tasks with complexity >= 5 benefit from expansion
334    /// Subtasks and already-expanded tasks don't need expansion
335    pub fn needs_expansion(&self) -> bool {
336        self.complexity >= 5 && !self.is_expanded() && !self.is_subtask()
337    }
338
339    /// Returns the recommended number of subtasks based on complexity
340    /// Complexity 0-3: 0 subtasks (trivial/simple, no expansion needed)
341    /// Complexity 5-8: 2 broad, multi-step subtasks
342    /// Complexity 13+: 3 broad, multi-step subtasks
343    pub fn recommended_subtasks(&self) -> usize {
344        Self::recommended_subtasks_for_complexity(self.complexity)
345    }
346
347    /// Static version for use when we only have complexity value
348    pub fn recommended_subtasks_for_complexity(complexity: u32) -> usize {
349        match complexity {
350            0..=3 => 0, // Trivial/simple tasks: no expansion needed
351            5 => 2,     // Moderate tasks: 2 broad subtasks
352            8 => 2,     // Complex tasks: 2 broad subtasks
353            13 => 3,    // Very complex: 3 broad subtasks
354            _ => 3,     // Extremely complex (21+): 3 broad subtasks max
355        }
356    }
357
358    // Assignment methods (informational only, no locking)
359    pub fn assign(&mut self, assignee: &str) {
360        self.assigned_to = Some(assignee.to_string());
361        self.update();
362    }
363
364    pub fn is_assigned_to(&self, assignee: &str) -> bool {
365        self.assigned_to
366            .as_ref()
367            .map(|s| s == assignee)
368            .unwrap_or(false)
369    }
370
371    /// Check if adding a dependency would create a circular reference
372    /// Returns Err with the cycle path if circular dependency detected
373    pub fn would_create_cycle(&self, new_dep_id: &str, all_tasks: &[Task]) -> Result<(), String> {
374        if self.id == new_dep_id {
375            return Err(format!("Self-reference: {} -> {}", self.id, new_dep_id));
376        }
377
378        let mut visited = std::collections::HashSet::new();
379        let mut path = Vec::new();
380
381        Self::detect_cycle_recursive(new_dep_id, &self.id, all_tasks, &mut visited, &mut path)
382    }
383
384    fn detect_cycle_recursive(
385        current_id: &str,
386        target_id: &str,
387        all_tasks: &[Task],
388        visited: &mut std::collections::HashSet<String>,
389        path: &mut Vec<String>,
390    ) -> Result<(), String> {
391        if current_id == target_id {
392            path.push(current_id.to_string());
393            return Err(format!("Circular dependency: {}", path.join(" -> ")));
394        }
395
396        if visited.contains(current_id) {
397            return Ok(());
398        }
399
400        visited.insert(current_id.to_string());
401        path.push(current_id.to_string());
402
403        if let Some(task) = all_tasks.iter().find(|t| t.id == current_id) {
404            for dep_id in &task.dependencies {
405                Self::detect_cycle_recursive(dep_id, target_id, all_tasks, visited, path)?;
406            }
407        }
408
409        path.pop();
410        Ok(())
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn test_task_creation() {
420        let task = Task::new(
421            "TASK-1".to_string(),
422            "Test Task".to_string(),
423            "Description".to_string(),
424        );
425
426        assert_eq!(task.id, "TASK-1");
427        assert_eq!(task.title, "Test Task");
428        assert_eq!(task.description, "Description");
429        assert_eq!(task.status, TaskStatus::Pending);
430        assert_eq!(task.complexity, 0);
431        assert_eq!(task.priority, Priority::Medium);
432        assert!(task.dependencies.is_empty());
433        assert!(task.created_at.is_some());
434        assert!(task.updated_at.is_some());
435        assert!(task.assigned_to.is_none());
436    }
437
438    #[test]
439    fn test_status_conversion() {
440        assert_eq!(TaskStatus::Pending.as_str(), "pending");
441        assert_eq!(TaskStatus::InProgress.as_str(), "in-progress");
442        assert_eq!(TaskStatus::Done.as_str(), "done");
443        assert_eq!(TaskStatus::Review.as_str(), "review");
444        assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
445        assert_eq!(TaskStatus::Deferred.as_str(), "deferred");
446        assert_eq!(TaskStatus::Cancelled.as_str(), "cancelled");
447    }
448
449    #[test]
450    fn test_status_from_string() {
451        assert_eq!(TaskStatus::from_str("pending"), Some(TaskStatus::Pending));
452        assert_eq!(
453            TaskStatus::from_str("in-progress"),
454            Some(TaskStatus::InProgress)
455        );
456        assert_eq!(TaskStatus::from_str("done"), Some(TaskStatus::Done));
457        assert_eq!(TaskStatus::from_str("invalid"), None);
458    }
459
460    #[test]
461    fn test_set_status_updates_timestamp() {
462        let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
463        let initial_updated = task.updated_at.clone();
464
465        std::thread::sleep(std::time::Duration::from_millis(10));
466        task.set_status(TaskStatus::InProgress);
467
468        assert_eq!(task.status, TaskStatus::InProgress);
469        assert!(task.updated_at > initial_updated);
470    }
471
472    #[test]
473    fn test_task_assignment() {
474        let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
475
476        task.assign("alice");
477        assert_eq!(task.assigned_to, Some("alice".to_string()));
478        assert!(task.is_assigned_to("alice"));
479        assert!(!task.is_assigned_to("bob"));
480    }
481
482    #[test]
483    fn test_has_dependencies_met_all_done() {
484        let mut task = Task::new("TASK-3".to_string(), "Test".to_string(), "Desc".to_string());
485        task.dependencies = vec!["TASK-1".to_string(), "TASK-2".to_string()];
486
487        let mut task1 = Task::new(
488            "TASK-1".to_string(),
489            "Dep 1".to_string(),
490            "Desc".to_string(),
491        );
492        task1.set_status(TaskStatus::Done);
493
494        let mut task2 = Task::new(
495            "TASK-2".to_string(),
496            "Dep 2".to_string(),
497            "Desc".to_string(),
498        );
499        task2.set_status(TaskStatus::Done);
500
501        let all_tasks = vec![task1, task2];
502        assert!(task.has_dependencies_met(&all_tasks));
503    }
504
505    #[test]
506    fn test_has_dependencies_met_some_pending() {
507        let mut task = Task::new("TASK-3".to_string(), "Test".to_string(), "Desc".to_string());
508        task.dependencies = vec!["TASK-1".to_string(), "TASK-2".to_string()];
509
510        let mut task1 = Task::new(
511            "TASK-1".to_string(),
512            "Dep 1".to_string(),
513            "Desc".to_string(),
514        );
515        task1.set_status(TaskStatus::Done);
516
517        let task2 = Task::new(
518            "TASK-2".to_string(),
519            "Dep 2".to_string(),
520            "Desc".to_string(),
521        );
522        // task2 is pending
523
524        let all_tasks = vec![task1, task2];
525        assert!(!task.has_dependencies_met(&all_tasks));
526    }
527
528    #[test]
529    fn test_has_dependencies_met_missing_dependency() {
530        let mut task = Task::new("TASK-3".to_string(), "Test".to_string(), "Desc".to_string());
531        task.dependencies = vec!["TASK-1".to_string(), "TASK-MISSING".to_string()];
532
533        let mut task1 = Task::new(
534            "TASK-1".to_string(),
535            "Dep 1".to_string(),
536            "Desc".to_string(),
537        );
538        task1.set_status(TaskStatus::Done);
539
540        let all_tasks = vec![task1];
541        assert!(!task.has_dependencies_met(&all_tasks));
542    }
543
544    #[test]
545    fn test_needs_expansion() {
546        let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
547
548        // Complexity < 5 should not need expansion
549        task.complexity = 1;
550        assert!(!task.needs_expansion());
551
552        task.complexity = 2;
553        assert!(!task.needs_expansion());
554
555        task.complexity = 3;
556        assert!(!task.needs_expansion());
557
558        // Complexity >= 5 should need expansion
559        task.complexity = 5;
560        assert!(task.needs_expansion());
561
562        task.complexity = 8;
563        assert!(task.needs_expansion());
564
565        task.complexity = 13;
566        assert!(task.needs_expansion());
567
568        task.complexity = 21;
569        assert!(task.needs_expansion());
570
571        // Already expanded tasks (with Expanded status) should not need expansion
572        task.status = TaskStatus::Expanded;
573        assert!(!task.needs_expansion());
574
575        // Reset status and test subtask case
576        task.status = TaskStatus::Pending;
577        task.parent_id = Some("parent:1".to_string());
578        assert!(!task.needs_expansion()); // Subtasks don't need expansion
579
580        // Reset and test tasks with subtasks
581        task.parent_id = None;
582        task.subtasks = vec!["TASK-1.1".to_string()];
583        assert!(!task.needs_expansion()); // Already has subtasks
584    }
585
586    #[test]
587    fn test_task_serialization() {
588        let task = Task::new(
589            "TASK-1".to_string(),
590            "Test Task".to_string(),
591            "Description".to_string(),
592        );
593
594        let json = serde_json::to_string(&task).unwrap();
595        let deserialized: Task = serde_json::from_str(&json).unwrap();
596
597        assert_eq!(task.id, deserialized.id);
598        assert_eq!(task.title, deserialized.title);
599        assert_eq!(task.description, deserialized.description);
600    }
601
602    #[test]
603    fn test_task_serialization_with_optional_fields() {
604        let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
605        task.details = Some("Detailed info".to_string());
606        task.test_strategy = Some("Test plan".to_string());
607        task.assign("alice");
608
609        let json = serde_json::to_string(&task).unwrap();
610        let deserialized: Task = serde_json::from_str(&json).unwrap();
611
612        assert_eq!(task.details, deserialized.details);
613        assert_eq!(task.test_strategy, deserialized.test_strategy);
614        assert_eq!(task.assigned_to, deserialized.assigned_to);
615    }
616
617    #[test]
618    fn test_priority_default() {
619        let default_priority = Priority::default();
620        assert_eq!(default_priority, Priority::Medium);
621    }
622
623    #[test]
624    fn test_status_all() {
625        let all_statuses = TaskStatus::all();
626        assert_eq!(all_statuses.len(), 8);
627        assert!(all_statuses.contains(&"pending"));
628        assert!(all_statuses.contains(&"in-progress"));
629        assert!(all_statuses.contains(&"done"));
630        assert!(all_statuses.contains(&"review"));
631        assert!(all_statuses.contains(&"blocked"));
632        assert!(all_statuses.contains(&"deferred"));
633        assert!(all_statuses.contains(&"cancelled"));
634        assert!(all_statuses.contains(&"expanded"));
635    }
636
637    #[test]
638    fn test_circular_dependency_self_reference() {
639        let task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
640        let all_tasks = vec![task.clone()];
641
642        let result = task.would_create_cycle("TASK-1", &all_tasks);
643        assert!(result.is_err());
644        assert!(result.unwrap_err().contains("Self-reference"));
645    }
646
647    #[test]
648    fn test_circular_dependency_direct_cycle() {
649        let mut task1 = Task::new(
650            "TASK-1".to_string(),
651            "Task 1".to_string(),
652            "Desc".to_string(),
653        );
654        task1.dependencies = vec!["TASK-2".to_string()];
655
656        let task2 = Task::new(
657            "TASK-2".to_string(),
658            "Task 2".to_string(),
659            "Desc".to_string(),
660        );
661
662        let all_tasks = vec![task1.clone(), task2.clone()];
663
664        // Trying to add TASK-1 as dependency of TASK-2 would create cycle: TASK-2 -> TASK-1 -> TASK-2
665        let result = task2.would_create_cycle("TASK-1", &all_tasks);
666        assert!(result.is_err());
667        assert!(result.unwrap_err().contains("Circular dependency"));
668    }
669
670    #[test]
671    fn test_circular_dependency_indirect_cycle() {
672        let mut task1 = Task::new(
673            "TASK-1".to_string(),
674            "Task 1".to_string(),
675            "Desc".to_string(),
676        );
677        task1.dependencies = vec!["TASK-2".to_string()];
678
679        let mut task2 = Task::new(
680            "TASK-2".to_string(),
681            "Task 2".to_string(),
682            "Desc".to_string(),
683        );
684        task2.dependencies = vec!["TASK-3".to_string()];
685
686        let task3 = Task::new(
687            "TASK-3".to_string(),
688            "Task 3".to_string(),
689            "Desc".to_string(),
690        );
691
692        let all_tasks = vec![task1.clone(), task2, task3.clone()];
693
694        // Trying to add TASK-1 as dependency of TASK-3 would create cycle:
695        // TASK-3 -> TASK-1 -> TASK-2 -> TASK-3
696        let result = task3.would_create_cycle("TASK-1", &all_tasks);
697        assert!(result.is_err());
698        assert!(result.unwrap_err().contains("Circular dependency"));
699    }
700
701    #[test]
702    fn test_circular_dependency_no_cycle() {
703        let mut task1 = Task::new(
704            "TASK-1".to_string(),
705            "Task 1".to_string(),
706            "Desc".to_string(),
707        );
708        task1.dependencies = vec!["TASK-3".to_string()];
709
710        let task2 = Task::new(
711            "TASK-2".to_string(),
712            "Task 2".to_string(),
713            "Desc".to_string(),
714        );
715
716        let task3 = Task::new(
717            "TASK-3".to_string(),
718            "Task 3".to_string(),
719            "Desc".to_string(),
720        );
721
722        let all_tasks = vec![task1.clone(), task2.clone(), task3];
723
724        // Adding TASK-2 as dependency of TASK-1 is fine (no cycle)
725        let result = task1.would_create_cycle("TASK-2", &all_tasks);
726        assert!(result.is_ok());
727    }
728
729    #[test]
730    fn test_circular_dependency_complex_graph() {
731        let mut task1 = Task::new(
732            "TASK-1".to_string(),
733            "Task 1".to_string(),
734            "Desc".to_string(),
735        );
736        task1.dependencies = vec!["TASK-2".to_string(), "TASK-3".to_string()];
737
738        let mut task2 = Task::new(
739            "TASK-2".to_string(),
740            "Task 2".to_string(),
741            "Desc".to_string(),
742        );
743        task2.dependencies = vec!["TASK-4".to_string()];
744
745        let mut task3 = Task::new(
746            "TASK-3".to_string(),
747            "Task 3".to_string(),
748            "Desc".to_string(),
749        );
750        task3.dependencies = vec!["TASK-4".to_string()];
751
752        let task4 = Task::new(
753            "TASK-4".to_string(),
754            "Task 4".to_string(),
755            "Desc".to_string(),
756        );
757
758        let all_tasks = vec![task1.clone(), task2, task3, task4.clone()];
759
760        // Adding TASK-1 as dependency of TASK-4 would create a cycle
761        let result = task4.would_create_cycle("TASK-1", &all_tasks);
762        assert!(result.is_err());
763        assert!(result.unwrap_err().contains("Circular dependency"));
764    }
765
766    // Validation tests
767    #[test]
768    fn test_validate_id_success() {
769        assert!(Task::validate_id("TASK-123").is_ok());
770        assert!(Task::validate_id("task_456").is_ok());
771        assert!(Task::validate_id("Feature-789").is_ok());
772        // Namespaced IDs
773        assert!(Task::validate_id("phase1:10").is_ok());
774        assert!(Task::validate_id("phase1:10.1").is_ok());
775        assert!(Task::validate_id("my-epic:subtask-1.2.3").is_ok());
776    }
777
778    #[test]
779    fn test_validate_id_empty() {
780        let result = Task::validate_id("");
781        assert!(result.is_err());
782        assert_eq!(result.unwrap_err(), "Task ID cannot be empty");
783    }
784
785    #[test]
786    fn test_validate_id_too_long() {
787        let long_id = "A".repeat(101);
788        let result = Task::validate_id(&long_id);
789        assert!(result.is_err());
790        assert!(result.unwrap_err().contains("too long"));
791    }
792
793    #[test]
794    fn test_validate_id_invalid_characters() {
795        assert!(Task::validate_id("TASK@123").is_err());
796        assert!(Task::validate_id("TASK 123").is_err());
797        assert!(Task::validate_id("TASK#123").is_err());
798        // Note: dot and colon are now valid for namespaced IDs
799        assert!(Task::validate_id("TASK.123").is_ok()); // Valid for subtask IDs like "10.1"
800        assert!(Task::validate_id("epic:TASK-1").is_ok()); // Valid namespaced ID
801    }
802
803    #[test]
804    fn test_validate_title_success() {
805        assert!(Task::validate_title("Valid title").is_ok());
806        assert!(Task::validate_title("A").is_ok());
807    }
808
809    #[test]
810    fn test_validate_title_empty() {
811        let result = Task::validate_title("");
812        assert!(result.is_err());
813        assert_eq!(result.unwrap_err(), "Task title cannot be empty");
814
815        let result = Task::validate_title("   ");
816        assert!(result.is_err());
817        assert_eq!(result.unwrap_err(), "Task title cannot be empty");
818    }
819
820    #[test]
821    fn test_validate_title_too_long() {
822        let long_title = "A".repeat(201);
823        let result = Task::validate_title(&long_title);
824        assert!(result.is_err());
825        assert!(result.unwrap_err().contains("too long"));
826    }
827
828    #[test]
829    fn test_validate_description_success() {
830        assert!(Task::validate_description("Valid description").is_ok());
831        assert!(Task::validate_description("").is_ok());
832    }
833
834    #[test]
835    fn test_validate_description_too_long() {
836        let long_desc = "A".repeat(5001);
837        let result = Task::validate_description(&long_desc);
838        assert!(result.is_err());
839        assert!(result.unwrap_err().contains("too long"));
840    }
841
842    #[test]
843    fn test_validate_complexity_success() {
844        assert!(Task::validate_complexity(0).is_ok());
845        assert!(Task::validate_complexity(1).is_ok());
846        assert!(Task::validate_complexity(2).is_ok());
847        assert!(Task::validate_complexity(3).is_ok());
848        assert!(Task::validate_complexity(5).is_ok());
849        assert!(Task::validate_complexity(8).is_ok());
850        assert!(Task::validate_complexity(13).is_ok());
851        assert!(Task::validate_complexity(21).is_ok());
852    }
853
854    #[test]
855    fn test_validate_complexity_invalid() {
856        assert!(Task::validate_complexity(4).is_err());
857        assert!(Task::validate_complexity(6).is_err());
858        assert!(Task::validate_complexity(7).is_err());
859        assert!(Task::validate_complexity(100).is_err());
860    }
861
862    #[test]
863    fn test_sanitize_text() {
864        assert_eq!(
865            Task::sanitize_text("<script>alert('xss')</script>"),
866            "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;"
867        );
868        assert_eq!(Task::sanitize_text("Normal text"), "Normal text");
869        assert_eq!(
870            Task::sanitize_text("<div>Content</div>"),
871            "&lt;div&gt;Content&lt;/div&gt;"
872        );
873    }
874
875    #[test]
876    fn test_validate_success() {
877        let task = Task::new(
878            "TASK-1".to_string(),
879            "Valid title".to_string(),
880            "Valid description".to_string(),
881        );
882        assert!(task.validate().is_ok());
883    }
884
885    #[test]
886    fn test_validate_multiple_errors() {
887        let mut task = Task::new("TASK@INVALID".to_string(), "".to_string(), "A".repeat(5001));
888        task.complexity = 100; // Invalid Fibonacci number
889
890        let result = task.validate();
891        assert!(result.is_err());
892        let errors = result.unwrap_err();
893        assert_eq!(errors.len(), 4);
894        assert!(errors.iter().any(|e| e.contains("ID")));
895        assert!(errors.iter().any(|e| e.contains("title")));
896        assert!(errors.iter().any(|e| e.contains("description")));
897        assert!(errors.iter().any(|e| e.contains("Complexity")));
898    }
899
900    // Cross-tag dependency tests
901    #[test]
902    fn test_cross_tag_dependency_met() {
903        let mut task_a = Task::new(
904            "auth:1".to_string(),
905            "Auth task".to_string(),
906            "Desc".to_string(),
907        );
908        task_a.set_status(TaskStatus::Done);
909
910        let mut task_b = Task::new(
911            "api:1".to_string(),
912            "API task".to_string(),
913            "Desc".to_string(),
914        );
915        task_b.dependencies = vec!["auth:1".to_string()];
916
917        let all_tasks = vec![&task_a, &task_b];
918        assert!(task_b.has_dependencies_met_refs(&all_tasks));
919    }
920
921    #[test]
922    fn test_cross_tag_dependency_not_met() {
923        let task_a = Task::new(
924            "auth:1".to_string(),
925            "Auth task".to_string(),
926            "Desc".to_string(),
927        );
928        // task_a still pending
929
930        let mut task_b = Task::new(
931            "api:1".to_string(),
932            "API task".to_string(),
933            "Desc".to_string(),
934        );
935        task_b.dependencies = vec!["auth:1".to_string()];
936
937        let all_tasks = vec![&task_a, &task_b];
938        assert!(!task_b.has_dependencies_met_refs(&all_tasks));
939    }
940
941    #[test]
942    fn test_local_dependency_still_works_with_refs() {
943        let mut task_a = Task::new("1".to_string(), "First".to_string(), "Desc".to_string());
944        task_a.set_status(TaskStatus::Done);
945
946        let mut task_b = Task::new("2".to_string(), "Second".to_string(), "Desc".to_string());
947        task_b.dependencies = vec!["1".to_string()];
948
949        let all_tasks = vec![&task_a, &task_b];
950        assert!(task_b.has_dependencies_met_refs(&all_tasks));
951    }
952
953    #[test]
954    fn test_has_dependencies_met_refs_missing_dependency() {
955        let mut task = Task::new("api:1".to_string(), "Test".to_string(), "Desc".to_string());
956        task.dependencies = vec!["auth:1".to_string(), "auth:MISSING".to_string()];
957
958        let mut dep1 = Task::new(
959            "auth:1".to_string(),
960            "Dep 1".to_string(),
961            "Desc".to_string(),
962        );
963        dep1.set_status(TaskStatus::Done);
964
965        let all_tasks = vec![&dep1];
966        assert!(!task.has_dependencies_met_refs(&all_tasks));
967    }
968
969    #[test]
970    fn test_subtask_inherits_parent_dependencies() {
971        // Parent task depends on cross-tag task "terminal:4"
972        let mut parent = Task::new(
973            "main:9".to_string(),
974            "Parent Task".to_string(),
975            "Desc".to_string(),
976        );
977        parent.dependencies = vec!["terminal:4".to_string()];
978        parent.status = TaskStatus::Expanded;
979        parent.subtasks = vec!["main:9.1".to_string()];
980
981        // Subtask has its own dependency on sibling 9.1 (none in this case)
982        let mut subtask = Task::new(
983            "main:9.1".to_string(),
984            "Subtask".to_string(),
985            "Desc".to_string(),
986        );
987        subtask.parent_id = Some("main:9".to_string());
988        // subtask has no direct dependencies, but should inherit parent's
989
990        // Cross-tag dependency (NOT done)
991        let terminal_task = Task::new(
992            "terminal:4".to_string(),
993            "Terminal Task".to_string(),
994            "Desc".to_string(),
995        );
996
997        let all_tasks = vec![&parent, &subtask, &terminal_task];
998
999        // Subtask should have effective dependency on terminal:4 (inherited from parent)
1000        let effective_deps = subtask.get_effective_dependencies(&all_tasks);
1001        assert!(
1002            effective_deps.contains(&"terminal:4".to_string()),
1003            "Subtask should inherit parent's cross-tag dependency"
1004        );
1005
1006        // Since terminal:4 is not done, subtask should be blocked
1007        assert!(
1008            !subtask.has_dependencies_met_refs(&all_tasks),
1009            "Subtask should be blocked when inherited dependency is not met"
1010        );
1011    }
1012
1013    #[test]
1014    fn test_subtask_inherits_parent_dependencies_met() {
1015        // Parent task depends on cross-tag task "terminal:4"
1016        let mut parent = Task::new(
1017            "main:9".to_string(),
1018            "Parent Task".to_string(),
1019            "Desc".to_string(),
1020        );
1021        parent.dependencies = vec!["terminal:4".to_string()];
1022        parent.status = TaskStatus::Expanded;
1023        parent.subtasks = vec!["main:9.1".to_string()];
1024
1025        // Subtask
1026        let mut subtask = Task::new(
1027            "main:9.1".to_string(),
1028            "Subtask".to_string(),
1029            "Desc".to_string(),
1030        );
1031        subtask.parent_id = Some("main:9".to_string());
1032
1033        // Cross-tag dependency (DONE)
1034        let mut terminal_task = Task::new(
1035            "terminal:4".to_string(),
1036            "Terminal Task".to_string(),
1037            "Desc".to_string(),
1038        );
1039        terminal_task.set_status(TaskStatus::Done);
1040
1041        let all_tasks = vec![&parent, &subtask, &terminal_task];
1042
1043        // Since terminal:4 is done, subtask should be available
1044        assert!(
1045            subtask.has_dependencies_met_refs(&all_tasks),
1046            "Subtask should be available when inherited dependency is met"
1047        );
1048    }
1049
1050    #[test]
1051    fn test_get_effective_dependencies_no_parent() {
1052        let mut task = Task::new("1".to_string(), "Task".to_string(), "Desc".to_string());
1053        task.dependencies = vec!["2".to_string(), "3".to_string()];
1054
1055        let all_tasks: Vec<&Task> = vec![&task];
1056        let effective = task.get_effective_dependencies(&all_tasks);
1057
1058        assert_eq!(effective, vec!["2".to_string(), "3".to_string()]);
1059    }
1060
1061    #[test]
1062    fn test_get_effective_dependencies_deduplication() {
1063        // Parent has deps A, B
1064        let mut parent = Task::new("parent".to_string(), "Parent".to_string(), "Desc".to_string());
1065        parent.dependencies = vec!["A".to_string(), "B".to_string()];
1066        parent.subtasks = vec!["child".to_string()];
1067
1068        // Child has deps B, C (B overlaps with parent)
1069        let mut child = Task::new("child".to_string(), "Child".to_string(), "Desc".to_string());
1070        child.parent_id = Some("parent".to_string());
1071        child.dependencies = vec!["B".to_string(), "C".to_string()];
1072
1073        let all_tasks = vec![&parent, &child];
1074        let effective = child.get_effective_dependencies(&all_tasks);
1075
1076        // Should have A, B, C (B deduplicated)
1077        assert_eq!(effective.len(), 3);
1078        assert!(effective.contains(&"A".to_string()));
1079        assert!(effective.contains(&"B".to_string()));
1080        assert!(effective.contains(&"C".to_string()));
1081    }
1082}