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