1use 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
53pub fn next_todo_task(queue: &QueueFile) -> Option<&Task> {
55 queue
56 .tasks
57 .iter()
58 .find(|task| task.status == TaskStatus::Todo)
59}
60
61pub 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, }
79 }
80
81 true
82}
83
84pub 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
99pub 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
108pub 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
117pub 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
154pub 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)), make_task("RQ-0002", TaskStatus::Todo, Some(&past)), ];
342 let active = QueueFile { version: 1, tasks };
343
344 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 let idx =
364 select_runnable_task_index(&active, None, RunnableSelectionOptions::new(false, false));
365 assert_eq!(idx, None);
366 }
367}