Skip to main content

scud_task_core/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, Default)]
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#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct ClaudeTask {
425    pub id: String,
426    pub subject: String,
427    pub description: String,
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub active_form: Option<String>,
430    pub status: ClaudeTaskStatus,
431    pub blocks: Vec<String>,
432    pub blocked_by: Vec<String>,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize)]
436#[serde(rename_all = "lowercase")]
437pub enum ClaudeTaskStatus {
438    Pending,
439    InProgress,
440    Completed,
441}
442
443impl From<TaskStatus> for ClaudeTaskStatus {
444    fn from(status: TaskStatus) -> Self {
445        match status {
446            TaskStatus::Pending => ClaudeTaskStatus::Pending,
447            TaskStatus::InProgress => ClaudeTaskStatus::InProgress,
448            TaskStatus::Done | TaskStatus::Review => ClaudeTaskStatus::Completed,
449            _ => ClaudeTaskStatus::Pending, // Map others conservatively
450        }
451    }
452}
453
454impl Task {
455    /// Convert SCUD DAG to flat Claude Tasks list
456    /// Flatten subtasks recursively, deps → blocked_by
457    pub fn to_claude_tasks(&self, all_tasks: &[Task]) -> Vec<ClaudeTask> {
458        let mut tasks = Vec::new();
459        self.flatten_to_claude(&mut tasks, all_tasks);
460        tasks
461    }
462
463    fn flatten_to_claude(&self, tasks: &mut Vec<ClaudeTask>, all_tasks: &[Task]) {
464        let claude_status = ClaudeTaskStatus::from(self.status.clone());
465        let claude_task = ClaudeTask {
466            id: self.local_id().to_string(),
467            subject: self.title.clone(),
468            description: self.description.clone(),
469            active_form: if self.status == TaskStatus::InProgress {
470                Some(self.title.clone()) // Or details?
471            } else {
472                None
473            },
474            status: claude_status,
475            blocks: self.subtasks.clone(),
476            blocked_by: self.dependencies.clone(),
477        };
478        tasks.push(claude_task);
479
480        // Recurse subtasks
481        for sub_id in &self.subtasks {
482            if let Some(sub) = all_tasks.iter().find(|t| &t.id == sub_id) {
483                sub.flatten_to_claude(tasks, all_tasks);
484            }
485        }
486    }
487
488    /// Build SCUD Task from ClaudeTask (single, no DAG restore)
489    pub fn from_claude_task(ct: &ClaudeTask) -> Self {
490        let status = match ct.status {
491            ClaudeTaskStatus::Pending => TaskStatus::Pending,
492            ClaudeTaskStatus::InProgress => TaskStatus::InProgress,
493            ClaudeTaskStatus::Completed => TaskStatus::Done,
494        };
495        Task {
496            id: ct.id.clone(),
497            title: ct.subject.clone(),
498            description: ct.description.clone(),
499            status,
500            dependencies: ct.blocked_by.clone(),
501            subtasks: ct.blocks.clone(),
502            ..Default::default()
503        }
504    }
505
506    /// Import full list to Vec<Task> (topological order?)
507    pub fn from_claude_tasks(cts: &[ClaudeTask]) -> Vec<Task> {
508        cts.iter().map(Task::from_claude_task).collect()
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn test_task_creation() {
518        let task = Task::new(
519            "TASK-1".to_string(),
520            "Test Task".to_string(),
521            "Description".to_string(),
522        );
523
524        assert_eq!(task.id, "TASK-1");
525        assert_eq!(task.title, "Test Task");
526        assert_eq!(task.description, "Description");
527        assert_eq!(task.status, TaskStatus::Pending);
528        assert_eq!(task.complexity, 0);
529        assert_eq!(task.priority, Priority::Medium);
530        assert!(task.dependencies.is_empty());
531        assert!(task.created_at.is_some());
532        assert!(task.updated_at.is_some());
533        assert!(task.assigned_to.is_none());
534    }
535
536    #[test]
537    fn test_status_conversion() {
538        assert_eq!(TaskStatus::Pending.as_str(), "pending");
539        assert_eq!(TaskStatus::InProgress.as_str(), "in-progress");
540        assert_eq!(TaskStatus::Done.as_str(), "done");
541        assert_eq!(TaskStatus::Review.as_str(), "review");
542        assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
543        assert_eq!(TaskStatus::Deferred.as_str(), "deferred");
544        assert_eq!(TaskStatus::Cancelled.as_str(), "cancelled");
545    }
546
547    #[test]
548    fn test_status_from_string() {
549        assert_eq!(TaskStatus::from_str("pending"), Some(TaskStatus::Pending));
550        assert_eq!(
551            TaskStatus::from_str("in-progress"),
552            Some(TaskStatus::InProgress)
553        );
554        assert_eq!(TaskStatus::from_str("done"), Some(TaskStatus::Done));
555        assert_eq!(TaskStatus::from_str("invalid"), None);
556    }
557
558    #[test]
559    fn test_set_status_updates_timestamp() {
560        let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
561        let initial_updated = task.updated_at.clone();
562
563        std::thread::sleep(std::time::Duration::from_millis(10));
564        task.set_status(TaskStatus::InProgress);
565
566        assert_eq!(task.status, TaskStatus::InProgress);
567        assert!(task.updated_at > initial_updated);
568    }
569
570    #[test]
571    fn test_task_assignment() {
572        let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
573
574        task.assign("alice");
575        assert_eq!(task.assigned_to, Some("alice".to_string()));
576        assert!(task.is_assigned_to("alice"));
577        assert!(!task.is_assigned_to("bob"));
578    }
579
580    #[test]
581    fn test_has_dependencies_met_all_done() {
582        let mut task = Task::new("TASK-3".to_string(), "Test".to_string(), "Desc".to_string());
583        task.dependencies = vec!["TASK-1".to_string(), "TASK-2".to_string()];
584
585        let mut task1 = Task::new(
586            "TASK-1".to_string(),
587            "Dep 1".to_string(),
588            "Desc".to_string(),
589        );
590        task1.set_status(TaskStatus::Done);
591
592        let mut task2 = Task::new(
593            "TASK-2".to_string(),
594            "Dep 2".to_string(),
595            "Desc".to_string(),
596        );
597        task2.set_status(TaskStatus::Done);
598
599        let all_tasks = vec![task1, task2];
600        assert!(task.has_dependencies_met(&all_tasks));
601    }
602
603    #[test]
604    fn test_needs_expansion() {
605        let mut task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
606
607        // Complexity < 5 should not need expansion
608        task.complexity = 1;
609        assert!(!task.needs_expansion());
610
611        task.complexity = 3;
612        assert!(!task.needs_expansion());
613
614        // Complexity >= 5 should need expansion
615        task.complexity = 5;
616        assert!(task.needs_expansion());
617
618        task.complexity = 8;
619        assert!(task.needs_expansion());
620
621        // Already expanded tasks should not need expansion
622        task.status = TaskStatus::Expanded;
623        assert!(!task.needs_expansion());
624
625        // Reset status and test subtask case
626        task.status = TaskStatus::Pending;
627        task.parent_id = Some("parent:1".to_string());
628        assert!(!task.needs_expansion()); // Subtasks don't need expansion
629    }
630
631    #[test]
632    fn test_validate_id_success() {
633        assert!(Task::validate_id("TASK-123").is_ok());
634        assert!(Task::validate_id("task_456").is_ok());
635        assert!(Task::validate_id("phase1:10").is_ok());
636        assert!(Task::validate_id("phase1:10.1").is_ok());
637    }
638
639    #[test]
640    fn test_validate_id_empty() {
641        let result = Task::validate_id("");
642        assert!(result.is_err());
643        assert_eq!(result.unwrap_err(), "Task ID cannot be empty");
644    }
645
646    #[test]
647    fn test_validate_complexity_success() {
648        assert!(Task::validate_complexity(0).is_ok());
649        assert!(Task::validate_complexity(1).is_ok());
650        assert!(Task::validate_complexity(2).is_ok());
651        assert!(Task::validate_complexity(3).is_ok());
652        assert!(Task::validate_complexity(5).is_ok());
653        assert!(Task::validate_complexity(8).is_ok());
654        assert!(Task::validate_complexity(13).is_ok());
655    }
656
657    #[test]
658    fn test_validate_complexity_invalid() {
659        assert!(Task::validate_complexity(4).is_err());
660        assert!(Task::validate_complexity(6).is_err());
661        assert!(Task::validate_complexity(7).is_err());
662    }
663
664    #[test]
665    fn test_circular_dependency_self_reference() {
666        let task = Task::new("TASK-1".to_string(), "Test".to_string(), "Desc".to_string());
667        let all_tasks = vec![task.clone()];
668
669        let result = task.would_create_cycle("TASK-1", &all_tasks);
670        assert!(result.is_err());
671        assert!(result.unwrap_err().contains("Self-reference"));
672    }
673
674    #[test]
675    fn test_priority_default() {
676        let default_priority = Priority::default();
677        assert_eq!(default_priority, Priority::Medium);
678    }
679
680    #[test]
681    fn test_status_all() {
682        let all_statuses = TaskStatus::all();
683        assert_eq!(all_statuses.len(), 9);
684        assert!(all_statuses.contains(&"pending"));
685        assert!(all_statuses.contains(&"in-progress"));
686        assert!(all_statuses.contains(&"done"));
687    }
688}