Skip to main content

ralph_core/
task.rs

1//! Task tracking for Ralph.
2//!
3//! Lightweight task tracking system inspired by Steve Yegge's Beads.
4//! Provides structured task data with JSONL persistence and dependency tracking.
5
6use serde::{Deserialize, Serialize};
7
8/// Status of a task.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum TaskStatus {
12    /// Not started
13    Open,
14    /// Being worked on
15    InProgress,
16    /// Complete
17    Closed,
18    /// Failed/abandoned
19    Failed,
20}
21
22impl TaskStatus {
23    /// Returns true if this status is terminal (Closed or Failed).
24    ///
25    /// Terminal statuses indicate the task is done and no longer needs attention.
26    pub fn is_terminal(&self) -> bool {
27        matches!(self, TaskStatus::Closed | TaskStatus::Failed)
28    }
29}
30
31/// A task in the task tracking system.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Task {
34    /// Unique ID: task-{unix_timestamp}-{4_hex_chars}
35    pub id: String,
36
37    /// Short description
38    pub title: String,
39
40    /// Optional detailed description
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub description: Option<String>,
43
44    /// Current state
45    pub status: TaskStatus,
46
47    /// Priority 1-5 (1 = highest)
48    pub priority: u8,
49
50    /// Tasks that must complete before this one
51    #[serde(default)]
52    pub blocked_by: Vec<String>,
53
54    /// Loop ID that created this task (from RALPH_LOOP_ID env var).
55    /// Used to filter tasks by ownership when multiple loops share a task list.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub loop_id: Option<String>,
58
59    /// Creation timestamp (ISO 8601)
60    pub created: String,
61
62    /// Completion timestamp (ISO 8601), if closed
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub closed: Option<String>,
65}
66
67impl Task {
68    /// Creates a new task with the given title and priority.
69    pub fn new(title: String, priority: u8) -> Self {
70        Self {
71            id: Self::generate_id(),
72            title,
73            description: None,
74            status: TaskStatus::Open,
75            priority: priority.clamp(1, 5),
76            blocked_by: Vec::new(),
77            loop_id: None,
78            created: chrono::Utc::now().to_rfc3339(),
79            closed: None,
80        }
81    }
82
83    /// Sets the loop ID for this task.
84    pub fn with_loop_id(mut self, loop_id: Option<String>) -> Self {
85        self.loop_id = loop_id;
86        self
87    }
88
89    /// Generates a unique task ID: task-{timestamp}-{hex_suffix}
90    pub fn generate_id() -> String {
91        use std::time::{SystemTime, UNIX_EPOCH};
92        let duration = SystemTime::now()
93            .duration_since(UNIX_EPOCH)
94            .expect("Time went backwards");
95        let timestamp = duration.as_secs();
96        let hex_suffix = format!("{:04x}", duration.subsec_micros() % 0x10000);
97        format!("task-{}-{}", timestamp, hex_suffix)
98    }
99
100    /// Returns true if this task is ready to work on (open + no blockers pending).
101    pub fn is_ready(&self, all_tasks: &[Task]) -> bool {
102        if self.status != TaskStatus::Open {
103            return false;
104        }
105        self.blocked_by.iter().all(|blocker_id| {
106            all_tasks
107                .iter()
108                .find(|t| &t.id == blocker_id)
109                .is_some_and(|t| t.status == TaskStatus::Closed)
110        })
111    }
112
113    /// Sets the description of the task.
114    pub fn with_description(mut self, description: Option<String>) -> Self {
115        self.description = description;
116        self
117    }
118
119    /// Adds a blocker task ID.
120    pub fn with_blocker(mut self, task_id: String) -> Self {
121        self.blocked_by.push(task_id);
122        self
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn test_task_creation() {
132        let task = Task::new("Test task".to_string(), 2);
133        assert_eq!(task.title, "Test task");
134        assert_eq!(task.priority, 2);
135        assert_eq!(task.status, TaskStatus::Open);
136        assert!(task.blocked_by.is_empty());
137    }
138
139    #[test]
140    fn test_priority_clamping() {
141        let task_low = Task::new("Low".to_string(), 0);
142        assert_eq!(task_low.priority, 1);
143
144        let task_high = Task::new("High".to_string(), 10);
145        assert_eq!(task_high.priority, 5);
146    }
147
148    #[test]
149    fn test_task_id_format() {
150        let task = Task::new("Test".to_string(), 1);
151        assert!(task.id.starts_with("task-"));
152        let parts: Vec<&str> = task.id.split('-').collect();
153        assert_eq!(parts.len(), 3);
154    }
155
156    #[test]
157    fn test_is_ready_open_no_blockers() {
158        let task = Task::new("Test".to_string(), 1);
159        assert!(task.is_ready(&[]));
160    }
161
162    #[test]
163    fn test_is_ready_with_open_blocker() {
164        let blocker = Task::new("Blocker".to_string(), 1);
165        let mut task = Task::new("Test".to_string(), 1);
166        task.blocked_by.push(blocker.id.clone());
167
168        assert!(!task.is_ready(std::slice::from_ref(&blocker)));
169    }
170
171    #[test]
172    fn test_is_ready_with_closed_blocker() {
173        let mut blocker = Task::new("Blocker".to_string(), 1);
174        blocker.status = TaskStatus::Closed;
175
176        let mut task = Task::new("Test".to_string(), 1);
177        task.blocked_by.push(blocker.id.clone());
178
179        assert!(task.is_ready(std::slice::from_ref(&blocker)));
180    }
181
182    #[test]
183    fn test_is_not_ready_when_not_open() {
184        let mut task = Task::new("Test".to_string(), 1);
185        task.status = TaskStatus::Closed;
186        assert!(!task.is_ready(&[]));
187
188        task.status = TaskStatus::InProgress;
189        assert!(!task.is_ready(&[]));
190
191        task.status = TaskStatus::Failed;
192        assert!(!task.is_ready(&[]));
193    }
194
195    #[test]
196    fn test_is_terminal() {
197        assert!(!TaskStatus::Open.is_terminal());
198        assert!(!TaskStatus::InProgress.is_terminal());
199        assert!(TaskStatus::Closed.is_terminal());
200        assert!(TaskStatus::Failed.is_terminal());
201    }
202}