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, Failed, }
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 #[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 #[serde(skip_serializing_if = "Option::is_none")]
113 pub assigned_to: Option<String>,
114
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub agent_type: Option<String>,
118}
119
120impl Task {
121 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 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 pub fn parse_id(id: &str) -> Option<(&str, &str)> {
154 id.split_once(Self::ID_SEPARATOR)
155 }
156
157 pub fn make_id(epic_tag: &str, local_id: &str) -> String {
159 format!("{}{}{}", epic_tag, Self::ID_SEPARATOR, local_id)
160 }
161
162 pub fn local_id(&self) -> &str {
164 Self::parse_id(&self.id)
165 .map(|(_, local)| local)
166 .unwrap_or(&self.id)
167 }
168
169 pub fn epic_tag(&self) -> Option<&str> {
171 Self::parse_id(&self.id).map(|(tag, _)| tag)
172 }
173
174 pub fn is_subtask(&self) -> bool {
176 self.parent_id.is_some()
177 }
178
179 pub fn is_expanded(&self) -> bool {
181 self.status == TaskStatus::Expanded || !self.subtasks.is_empty()
182 }
183
184 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 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 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 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 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 pub fn sanitize_text(text: &str) -> String {
252 text.replace('<', "<")
253 .replace('>', ">")
254 .replace('"', """)
255 .replace('\'', "'")
256 }
257
258 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 pub fn get_effective_dependencies(&self, all_tasks: &[&Task]) -> Vec<String> {
309 let mut deps = self.dependencies.clone();
310
311 if let Some(ref parent_id) = self.parent_id {
313 if let Some(parent) = all_tasks.iter().find(|t| &t.id == parent_id) {
314 let parent_deps = parent.get_effective_dependencies(all_tasks);
316 deps.extend(parent_deps);
317 }
318 }
319
320 deps.sort();
322 deps.dedup();
323 deps
324 }
325
326 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 pub fn needs_expansion(&self) -> bool {
345 self.complexity >= 5 && !self.is_expanded() && !self.is_subtask()
346 }
347
348 pub fn recommended_subtasks(&self) -> usize {
353 Self::recommended_subtasks_for_complexity(self.complexity)
354 }
355
356 pub fn recommended_subtasks_for_complexity(complexity: u32) -> usize {
358 match complexity {
359 0..=3 => 0, 5 => 2, 8 => 2, 13 => 3, _ => 3, }
365 }
366
367 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 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, }
451 }
452}
453
454impl Task {
455 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()) } 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 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 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 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 task.complexity = 1;
609 assert!(!task.needs_expansion());
610
611 task.complexity = 3;
612 assert!(!task.needs_expansion());
613
614 task.complexity = 5;
616 assert!(task.needs_expansion());
617
618 task.complexity = 8;
619 assert!(task.needs_expansion());
620
621 task.status = TaskStatus::Expanded;
623 assert!(!task.needs_expansion());
624
625 task.status = TaskStatus::Pending;
627 task.parent_id = Some("parent:1".to_string());
628 assert!(!task.needs_expansion()); }
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}