Skip to main content

ralph/queue/operations/
query.rs

1//! Query helpers for queue tasks.
2//!
3//! Responsibilities:
4//! - Locate tasks in active/done queues and determine runnable indices.
5//! - Enforce runnable status and dependency rules for selection.
6//! - Emit typed `QueueQueryError` for stable test assertions.
7//!
8//! Does not handle:
9//! - Persisting queue data or mutating task fields.
10//! - Normalizing IDs beyond trimming whitespace.
11//!
12//! Assumptions/invariants:
13//! - Queues are already loaded and represent the source of truth.
14//! - Task IDs are matched after trimming and are case-sensitive.
15//! - Query errors wrap typed `QueueQueryError` variants for downcasting in tests.
16
17use super::QueueQueryError;
18use crate::contracts::{QueueFile, Task, TaskStatus};
19use crate::timeutil;
20use anyhow::Result;
21
22pub fn find_task<'a>(queue: &'a QueueFile, task_id: &str) -> Option<&'a Task> {
23    let needle = task_id.trim();
24    if needle.is_empty() {
25        return None;
26    }
27    queue.tasks.iter().find(|task| task.id.trim() == needle)
28}
29
30pub fn find_task_across<'a>(
31    active: &'a QueueFile,
32    done: Option<&'a QueueFile>,
33    task_id: &str,
34) -> Option<&'a Task> {
35    find_task(active, task_id).or_else(|| done.and_then(|d| find_task(d, task_id)))
36}
37
38#[derive(Clone, Copy, Debug)]
39pub struct RunnableSelectionOptions {
40    pub include_draft: bool,
41    pub prefer_doing: bool,
42}
43
44impl RunnableSelectionOptions {
45    pub fn new(include_draft: bool, prefer_doing: bool) -> Self {
46        Self {
47            include_draft,
48            prefer_doing,
49        }
50    }
51}
52
53/// Return the first todo task by file order (top-of-file wins).
54pub fn next_todo_task(queue: &QueueFile) -> Option<&Task> {
55    queue
56        .tasks
57        .iter()
58        .find(|task| task.status == TaskStatus::Todo)
59}
60
61/// Check if a task's dependencies are met.
62///
63/// Dependencies are met if `depends_on` is empty OR all referenced tasks exist and have `status == TaskStatus::Done` or `TaskStatus::Rejected`.
64pub fn are_dependencies_met(task: &Task, active: &QueueFile, done: Option<&QueueFile>) -> bool {
65    if task.depends_on.is_empty() {
66        return true;
67    }
68
69    for dep_id in &task.depends_on {
70        let dep_task = find_task_across(active, done, dep_id);
71        match dep_task {
72            Some(t) => {
73                if t.status != TaskStatus::Done && t.status != TaskStatus::Rejected {
74                    return false;
75                }
76            }
77            None => return false, // Dependency not found means not met
78        }
79    }
80
81    true
82}
83
84/// Check if a task's scheduled_start is in the future.
85///
86/// Returns true if the task has a scheduled_start timestamp that is
87/// in the future relative to the current time.
88pub fn is_task_scheduled_for_future(task: &Task) -> bool {
89    if let Some(ref scheduled) = task.scheduled_start
90        && let Ok(scheduled_dt) = timeutil::parse_rfc3339(scheduled)
91        && let Ok(now) = timeutil::now_utc_rfc3339()
92        && let Ok(now_dt) = timeutil::parse_rfc3339(&now)
93    {
94        return scheduled_dt > now_dt;
95    }
96    false
97}
98
99/// Check if a task is runnable (dependencies met and scheduling satisfied).
100///
101/// A task is runnable if:
102/// - All dependencies are met (depends_on tasks are Done or Rejected)
103/// - The scheduled_start time has passed (or is not set)
104pub fn is_task_runnable(task: &Task, active: &QueueFile, done: Option<&QueueFile>) -> bool {
105    are_dependencies_met(task, active, done) && !is_task_scheduled_for_future(task)
106}
107
108/// Return the first runnable task (Todo and dependencies met).
109pub fn next_runnable_task<'a>(
110    active: &'a QueueFile,
111    done: Option<&'a QueueFile>,
112) -> Option<&'a Task> {
113    select_runnable_task_index(active, done, RunnableSelectionOptions::new(false, false))
114        .and_then(|idx| active.tasks.get(idx))
115}
116
117/// Select the next runnable task index according to the provided options.
118///
119/// Order:
120/// - If `prefer_doing` is true, prefer the first `Doing` task.
121/// - Otherwise, choose the first runnable `Todo`.
122/// - If `include_draft` is true and no runnable `Todo` exists, choose the first runnable `Draft`.
123pub fn select_runnable_task_index(
124    active: &QueueFile,
125    done: Option<&QueueFile>,
126    options: RunnableSelectionOptions,
127) -> Option<usize> {
128    if options.prefer_doing
129        && let Some(idx) = active
130            .tasks
131            .iter()
132            .position(|task| task.status == TaskStatus::Doing)
133    {
134        return Some(idx);
135    }
136
137    if let Some(idx) = active
138        .tasks
139        .iter()
140        .position(|task| task.status == TaskStatus::Todo && is_task_runnable(task, active, done))
141    {
142        return Some(idx);
143    }
144
145    if options.include_draft {
146        return active.tasks.iter().position(|task| {
147            task.status == TaskStatus::Draft && is_task_runnable(task, active, done)
148        });
149    }
150
151    None
152}
153
154/// Select a runnable task index by target task id, with validation.
155pub fn select_runnable_task_index_with_target(
156    active: &QueueFile,
157    done: Option<&QueueFile>,
158    target_task_id: &str,
159    operation: &str,
160    options: RunnableSelectionOptions,
161) -> Result<usize> {
162    let needle = target_task_id.trim();
163    if needle.is_empty() {
164        return Err(QueueQueryError::MissingTargetTaskId {
165            operation: operation.to_string(),
166        }
167        .into());
168    }
169    let idx = active
170        .tasks
171        .iter()
172        .position(|task| task.id.trim() == needle)
173        .ok_or_else(|| QueueQueryError::TargetTaskNotFound {
174            operation: operation.to_string(),
175            task_id: needle.to_string(),
176        })?;
177    let task = &active.tasks[idx];
178    match task.status {
179        TaskStatus::Done | TaskStatus::Rejected => {
180            return Err(QueueQueryError::TargetTaskNotRunnable {
181                operation: operation.to_string(),
182                task_id: needle.to_string(),
183                status: task.status,
184            }
185            .into());
186        }
187        TaskStatus::Draft => {
188            if !options.include_draft {
189                return Err(QueueQueryError::TargetTaskDraftExcluded {
190                    operation: operation.to_string(),
191                    task_id: needle.to_string(),
192                }
193                .into());
194            }
195            if !are_dependencies_met(task, active, done) {
196                return Err(QueueQueryError::TargetTaskBlockedByUnmetDependencies {
197                    operation: operation.to_string(),
198                    task_id: needle.to_string(),
199                }
200                .into());
201            }
202            if is_task_scheduled_for_future(task) {
203                return Err(QueueQueryError::TargetTaskScheduledForFuture {
204                    operation: operation.to_string(),
205                    task_id: needle.to_string(),
206                    scheduled_start: task
207                        .scheduled_start
208                        .as_deref()
209                        .unwrap_or("unknown")
210                        .to_string(),
211                }
212                .into());
213            }
214        }
215        TaskStatus::Todo => {
216            if !are_dependencies_met(task, active, done) {
217                return Err(QueueQueryError::TargetTaskBlockedByUnmetDependencies {
218                    operation: operation.to_string(),
219                    task_id: needle.to_string(),
220                }
221                .into());
222            }
223            if is_task_scheduled_for_future(task) {
224                return Err(QueueQueryError::TargetTaskScheduledForFuture {
225                    operation: operation.to_string(),
226                    task_id: needle.to_string(),
227                    scheduled_start: task
228                        .scheduled_start
229                        .as_deref()
230                        .unwrap_or("unknown")
231                        .to_string(),
232                }
233                .into());
234            }
235        }
236        TaskStatus::Doing => {}
237    }
238
239    Ok(idx)
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::contracts::{QueueFile, Task, TaskStatus};
246    use std::collections::HashMap;
247    use time::OffsetDateTime;
248
249    fn make_task(id: &str, status: TaskStatus, scheduled_start: Option<&str>) -> Task {
250        Task {
251            id: id.to_string(),
252            status,
253            title: format!("Task {}", id),
254            description: None,
255            priority: Default::default(),
256            tags: vec![],
257            scope: vec![],
258            evidence: vec![],
259            plan: vec![],
260            notes: vec![],
261            request: None,
262            agent: None,
263            created_at: Some("2026-01-18T00:00:00Z".to_string()),
264            updated_at: Some("2026-01-18T00:00:00Z".to_string()),
265            completed_at: None,
266            started_at: None,
267            scheduled_start: scheduled_start.map(|s| s.to_string()),
268            estimated_minutes: None,
269            actual_minutes: None,
270            depends_on: vec![],
271            blocks: vec![],
272            relates_to: vec![],
273            duplicates: None,
274            custom_fields: HashMap::new(),
275            parent_id: None,
276        }
277    }
278
279    #[test]
280    fn test_is_task_scheduled_for_future_with_future_date() {
281        let future = (OffsetDateTime::now_utc() + time::Duration::hours(24))
282            .format(&time::format_description::well_known::Rfc3339)
283            .unwrap();
284        let task = make_task("RQ-0001", TaskStatus::Todo, Some(&future));
285        assert!(is_task_scheduled_for_future(&task));
286    }
287
288    #[test]
289    fn test_is_task_scheduled_for_future_with_past_date() {
290        let past = (OffsetDateTime::now_utc() - time::Duration::hours(24))
291            .format(&time::format_description::well_known::Rfc3339)
292            .unwrap();
293        let task = make_task("RQ-0001", TaskStatus::Todo, Some(&past));
294        assert!(!is_task_scheduled_for_future(&task));
295    }
296
297    #[test]
298    fn test_is_task_scheduled_for_future_with_no_schedule() {
299        let task = make_task("RQ-0001", TaskStatus::Todo, None);
300        assert!(!is_task_scheduled_for_future(&task));
301    }
302
303    #[test]
304    fn test_is_task_runnable_with_schedule_and_dependencies() {
305        let past = (OffsetDateTime::now_utc() - time::Duration::hours(24))
306            .format(&time::format_description::well_known::Rfc3339)
307            .unwrap();
308        let task = make_task("RQ-0001", TaskStatus::Todo, Some(&past));
309        let active = QueueFile {
310            version: 1,
311            tasks: vec![task.clone()],
312        };
313        assert!(is_task_runnable(&task, &active, None));
314    }
315
316    #[test]
317    fn test_is_task_not_runnable_with_future_schedule() {
318        let future = (OffsetDateTime::now_utc() + time::Duration::hours(24))
319            .format(&time::format_description::well_known::Rfc3339)
320            .unwrap();
321        let task = make_task("RQ-0001", TaskStatus::Todo, Some(&future));
322        let active = QueueFile {
323            version: 1,
324            tasks: vec![task.clone()],
325        };
326        assert!(!is_task_runnable(&task, &active, None));
327    }
328
329    #[test]
330    fn test_select_runnable_task_index_skips_future_scheduled() {
331        let future = (OffsetDateTime::now_utc() + time::Duration::hours(24))
332            .format(&time::format_description::well_known::Rfc3339)
333            .unwrap();
334        let past = (OffsetDateTime::now_utc() - time::Duration::hours(24))
335            .format(&time::format_description::well_known::Rfc3339)
336            .unwrap();
337
338        let tasks = vec![
339            make_task("RQ-0001", TaskStatus::Todo, Some(&future)), // scheduled future
340            make_task("RQ-0002", TaskStatus::Todo, Some(&past)),   // scheduled past (runnable)
341        ];
342        let active = QueueFile { version: 1, tasks };
343
344        // Should select RQ-0002 (index 1) since RQ-0001 is scheduled for future
345        let idx =
346            select_runnable_task_index(&active, None, RunnableSelectionOptions::new(false, false));
347        assert_eq!(idx, Some(1));
348    }
349
350    #[test]
351    fn test_select_runnable_task_index_all_future_scheduled() {
352        let future = (OffsetDateTime::now_utc() + time::Duration::hours(24))
353            .format(&time::format_description::well_known::Rfc3339)
354            .unwrap();
355
356        let tasks = vec![
357            make_task("RQ-0001", TaskStatus::Todo, Some(&future)),
358            make_task("RQ-0002", TaskStatus::Todo, Some(&future)),
359        ];
360        let active = QueueFile { version: 1, tasks };
361
362        // No runnable tasks
363        let idx =
364            select_runnable_task_index(&active, None, RunnableSelectionOptions::new(false, false));
365        assert_eq!(idx, None);
366    }
367}