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    /// Stable key for idempotent orchestrator-managed tasks.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub key: Option<String>,
47
48    /// Current state
49    pub status: TaskStatus,
50
51    /// Priority 1-5 (1 = highest)
52    pub priority: u8,
53
54    /// Tasks that must complete before this one
55    #[serde(default)]
56    pub blocked_by: Vec<String>,
57
58    /// Loop ID that created this task (from RALPH_LOOP_ID env var).
59    /// Used to filter tasks by ownership when multiple loops share a task list.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub loop_id: Option<String>,
62
63    /// Creation timestamp (ISO 8601)
64    pub created: String,
65
66    /// Start timestamp (ISO 8601), if the task entered in_progress.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub started: Option<String>,
69
70    /// Completion timestamp (ISO 8601), if closed
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub closed: Option<String>,
73}
74
75impl Task {
76    /// Creates a new task with the given title and priority.
77    pub fn new(title: String, priority: u8) -> Self {
78        Self {
79            id: Self::generate_id(),
80            title,
81            description: None,
82            key: None,
83            status: TaskStatus::Open,
84            priority: priority.clamp(1, 5),
85            blocked_by: Vec::new(),
86            loop_id: None,
87            created: chrono::Utc::now().to_rfc3339(),
88            started: None,
89            closed: None,
90        }
91    }
92
93    /// Sets the loop ID for this task.
94    pub fn with_loop_id(mut self, loop_id: Option<String>) -> Self {
95        self.loop_id = loop_id;
96        self
97    }
98
99    /// Generates a unique task ID: task-{timestamp}-{hex_suffix}
100    pub fn generate_id() -> String {
101        use std::time::{SystemTime, UNIX_EPOCH};
102        let duration = SystemTime::now()
103            .duration_since(UNIX_EPOCH)
104            .expect("Time went backwards");
105        let timestamp = duration.as_secs();
106        let hex_suffix = format!("{:04x}", duration.subsec_micros() % 0x10000);
107        format!("task-{}-{}", timestamp, hex_suffix)
108    }
109
110    /// Returns true if this task is ready to work on (open + no blockers pending).
111    pub fn is_ready(&self, all_tasks: &[Task]) -> bool {
112        if self.status != TaskStatus::Open {
113            return false;
114        }
115        self.blocked_by.iter().all(|blocker_id| {
116            all_tasks
117                .iter()
118                .find(|t| &t.id == blocker_id)
119                .is_some_and(|t| t.status == TaskStatus::Closed)
120        })
121    }
122
123    /// Sets the description of the task.
124    pub fn with_description(mut self, description: Option<String>) -> Self {
125        self.description = description;
126        self
127    }
128
129    /// Sets the stable orchestration key for the task.
130    pub fn with_key(mut self, key: Option<String>) -> Self {
131        self.key = key;
132        self
133    }
134
135    /// Adds a blocker task ID.
136    pub fn with_blocker(mut self, task_id: String) -> Self {
137        self.blocked_by.push(task_id);
138        self
139    }
140
141    /// Marks the task as in progress and records a start timestamp if absent.
142    pub fn start(&mut self) {
143        self.status = TaskStatus::InProgress;
144        if self.started.is_none() {
145            self.started = Some(chrono::Utc::now().to_rfc3339());
146        }
147        self.closed = None;
148    }
149
150    /// Reopens a terminal task for further work.
151    pub fn reopen(&mut self) {
152        self.status = TaskStatus::Open;
153        self.closed = None;
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_task_creation() {
163        let task = Task::new("Test task".to_string(), 2);
164        assert_eq!(task.title, "Test task");
165        assert_eq!(task.priority, 2);
166        assert_eq!(task.status, TaskStatus::Open);
167        assert!(task.blocked_by.is_empty());
168        assert!(task.key.is_none());
169        assert!(task.started.is_none());
170    }
171
172    #[test]
173    fn test_priority_clamping() {
174        let task_low = Task::new("Low".to_string(), 0);
175        assert_eq!(task_low.priority, 1);
176
177        let task_high = Task::new("High".to_string(), 10);
178        assert_eq!(task_high.priority, 5);
179    }
180
181    #[test]
182    fn test_task_id_format() {
183        let task = Task::new("Test".to_string(), 1);
184        assert!(task.id.starts_with("task-"));
185        let parts: Vec<&str> = task.id.split('-').collect();
186        assert_eq!(parts.len(), 3);
187    }
188
189    #[test]
190    fn test_is_ready_open_no_blockers() {
191        let task = Task::new("Test".to_string(), 1);
192        assert!(task.is_ready(&[]));
193    }
194
195    #[test]
196    fn test_is_ready_with_open_blocker() {
197        let blocker = Task::new("Blocker".to_string(), 1);
198        let mut task = Task::new("Test".to_string(), 1);
199        task.blocked_by.push(blocker.id.clone());
200
201        assert!(!task.is_ready(std::slice::from_ref(&blocker)));
202    }
203
204    #[test]
205    fn test_is_ready_with_closed_blocker() {
206        let mut blocker = Task::new("Blocker".to_string(), 1);
207        blocker.status = TaskStatus::Closed;
208
209        let mut task = Task::new("Test".to_string(), 1);
210        task.blocked_by.push(blocker.id.clone());
211
212        assert!(task.is_ready(std::slice::from_ref(&blocker)));
213    }
214
215    #[test]
216    fn test_is_not_ready_when_not_open() {
217        let mut task = Task::new("Test".to_string(), 1);
218        task.status = TaskStatus::Closed;
219        assert!(!task.is_ready(&[]));
220
221        task.status = TaskStatus::InProgress;
222        assert!(!task.is_ready(&[]));
223
224        task.status = TaskStatus::Failed;
225        assert!(!task.is_ready(&[]));
226    }
227
228    #[test]
229    fn test_is_terminal() {
230        assert!(!TaskStatus::Open.is_terminal());
231        assert!(!TaskStatus::InProgress.is_terminal());
232        assert!(TaskStatus::Closed.is_terminal());
233        assert!(TaskStatus::Failed.is_terminal());
234    }
235
236    #[test]
237    fn test_with_key_sets_stable_key() {
238        let task = Task::new("Test".to_string(), 1).with_key(Some("spec:build".to_string()));
239        assert_eq!(task.key.as_deref(), Some("spec:build"));
240    }
241
242    #[test]
243    fn test_start_marks_task_in_progress() {
244        let mut task = Task::new("Test".to_string(), 1);
245        task.start();
246        assert_eq!(task.status, TaskStatus::InProgress);
247        assert!(task.started.is_some());
248        assert!(task.closed.is_none());
249    }
250
251    #[test]
252    fn test_reopen_resets_terminal_state() {
253        let mut task = Task::new("Test".to_string(), 1);
254        task.status = TaskStatus::Closed;
255        task.closed = Some(chrono::Utc::now().to_rfc3339());
256        task.reopen();
257        assert_eq!(task.status, TaskStatus::Open);
258        assert!(task.closed.is_none());
259    }
260}