1use crate::contracts::{QueueFile, Task, TaskStatus};
16use crate::queue::operations::{RunnableSelectionOptions, find_task_across};
17use anyhow::Result;
18use serde::Serialize;
19
20pub const RUNNABILITY_REPORT_VERSION: u32 = 1;
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25#[serde(rename_all = "snake_case")]
26pub struct QueueRunnabilityReport {
27 pub version: u32,
28 pub now: String,
29 pub selection: QueueRunnabilitySelection,
30 pub summary: QueueRunnabilitySummary,
31 pub tasks: Vec<TaskRunnabilityRow>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "snake_case")]
37pub struct QueueRunnabilitySelection {
38 pub include_draft: bool,
39 pub prefer_doing: bool,
40 pub selected_task_id: Option<String>,
41 pub selected_task_status: Option<TaskStatus>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
46#[serde(rename_all = "snake_case")]
47pub struct QueueRunnabilitySummary {
48 pub total_active: usize,
49 pub candidates_total: usize,
50 pub runnable_candidates: usize,
51 pub blocked_by_dependencies: usize,
52 pub blocked_by_schedule: usize,
53 pub blocked_by_status_or_flags: usize,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
58#[serde(rename_all = "snake_case")]
59pub struct TaskRunnabilityRow {
60 pub id: String,
61 pub status: TaskStatus,
62 pub runnable: bool,
63 pub reasons: Vec<NotRunnableReason>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68#[serde(tag = "kind", rename_all = "snake_case")]
69pub enum NotRunnableReason {
70 StatusNotRunnable { status: TaskStatus },
72 DraftExcluded,
74 UnmetDependencies { dependencies: Vec<DependencyIssue> },
76 ScheduledStartInFuture {
78 scheduled_start: String,
79 now: String,
80 seconds_until_runnable: i64,
81 },
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
86#[serde(tag = "kind", rename_all = "snake_case")]
87pub enum DependencyIssue {
88 Missing { id: String },
90 NotComplete { id: String, status: TaskStatus },
92}
93
94pub fn queue_runnability_report(
96 active: &QueueFile,
97 done: Option<&QueueFile>,
98 options: RunnableSelectionOptions,
99) -> Result<QueueRunnabilityReport> {
100 let now = crate::timeutil::now_utc_rfc3339()?;
101 queue_runnability_report_at(&now, active, done, options)
102}
103
104pub fn queue_runnability_report_at(
106 now_rfc3339: &str,
107 active: &QueueFile,
108 done: Option<&QueueFile>,
109 options: RunnableSelectionOptions,
110) -> Result<QueueRunnabilityReport> {
111 let now_dt = crate::timeutil::parse_rfc3339(now_rfc3339)?;
112
113 let mut tasks = Vec::with_capacity(active.tasks.len());
114 let mut candidates_total = 0usize;
115 let mut runnable_candidates = 0usize;
116 let mut blocked_by_deps = 0usize;
117 let mut blocked_by_schedule = 0usize;
118 let mut blocked_by_status = 0usize;
119
120 let mut selected_task_id: Option<String> = None;
122 let mut selected_task_status: Option<TaskStatus> = None;
123
124 for task in &active.tasks {
125 let row = analyze_task_runnability(task, active, done, now_rfc3339, now_dt, options);
126
127 let is_candidate = row.status == TaskStatus::Todo
129 || (options.include_draft && row.status == TaskStatus::Draft);
130
131 if is_candidate {
132 candidates_total += 1;
133 if row.runnable {
134 runnable_candidates += 1;
135 } else {
136 for reason in &row.reasons {
138 match reason {
139 NotRunnableReason::StatusNotRunnable { .. }
140 | NotRunnableReason::DraftExcluded => {
141 blocked_by_status += 1;
142 }
143 NotRunnableReason::UnmetDependencies { .. } => {
144 blocked_by_deps += 1;
145 }
146 NotRunnableReason::ScheduledStartInFuture { .. } => {
147 blocked_by_schedule += 1;
148 }
149 }
150 }
151 }
152 }
153
154 tasks.push(row);
155 }
156
157 if options.prefer_doing
159 && let Some(task) = active.tasks.iter().find(|t| t.status == TaskStatus::Doing)
160 {
161 selected_task_id = Some(task.id.clone());
162 selected_task_status = Some(TaskStatus::Doing);
163 }
164
165 if selected_task_id.is_none() {
166 for row in &tasks {
167 if row.runnable {
168 if row.status == TaskStatus::Todo {
169 selected_task_id = Some(row.id.clone());
170 selected_task_status = Some(TaskStatus::Todo);
171 break;
172 }
173 if options.include_draft && row.status == TaskStatus::Draft {
174 selected_task_id = Some(row.id.clone());
175 selected_task_status = Some(TaskStatus::Draft);
176 break;
177 }
178 }
179 }
180 }
181
182 Ok(QueueRunnabilityReport {
183 version: RUNNABILITY_REPORT_VERSION,
184 now: now_rfc3339.to_string(),
185 selection: QueueRunnabilitySelection {
186 include_draft: options.include_draft,
187 prefer_doing: options.prefer_doing,
188 selected_task_id,
189 selected_task_status,
190 },
191 summary: QueueRunnabilitySummary {
192 total_active: active.tasks.len(),
193 candidates_total,
194 runnable_candidates,
195 blocked_by_dependencies: blocked_by_deps,
196 blocked_by_schedule,
197 blocked_by_status_or_flags: blocked_by_status,
198 },
199 tasks,
200 })
201}
202
203fn analyze_task_runnability(
205 task: &Task,
206 active: &QueueFile,
207 done: Option<&QueueFile>,
208 now_rfc3339: &str,
209 now_dt: time::OffsetDateTime,
210 options: RunnableSelectionOptions,
211) -> TaskRunnabilityRow {
212 let mut reasons = Vec::new();
213 let mut runnable = true;
214
215 match task.status {
217 TaskStatus::Done | TaskStatus::Rejected => {
218 runnable = false;
219 reasons.push(NotRunnableReason::StatusNotRunnable {
220 status: task.status,
221 });
222 }
223 TaskStatus::Draft => {
224 if !options.include_draft {
225 runnable = false;
226 reasons.push(NotRunnableReason::DraftExcluded);
227 }
228 }
229 TaskStatus::Todo | TaskStatus::Doing => {}
230 }
231
232 if runnable || reasons.is_empty() {
234 let dep_issues = check_dependencies(task, active, done);
235 if !dep_issues.is_empty() {
236 runnable = false;
237 reasons.push(NotRunnableReason::UnmetDependencies {
238 dependencies: dep_issues,
239 });
240 }
241 }
242
243 if (runnable
245 || reasons
246 .iter()
247 .all(|r| !matches!(r, NotRunnableReason::StatusNotRunnable { .. })))
248 && let Some(ref scheduled) = task.scheduled_start
249 && let Ok(scheduled_dt) = crate::timeutil::parse_rfc3339(scheduled)
250 && scheduled_dt > now_dt
251 {
252 runnable = false;
253 let seconds_until = (scheduled_dt - now_dt).whole_seconds();
254 reasons.push(NotRunnableReason::ScheduledStartInFuture {
255 scheduled_start: scheduled.clone(),
256 now: now_rfc3339.to_string(),
257 seconds_until_runnable: seconds_until,
258 });
259 }
260
261 TaskRunnabilityRow {
262 id: task.id.clone(),
263 status: task.status,
264 runnable,
265 reasons,
266 }
267}
268
269fn check_dependencies(
271 task: &Task,
272 active: &QueueFile,
273 done: Option<&QueueFile>,
274) -> Vec<DependencyIssue> {
275 let mut issues = Vec::new();
276
277 for dep_id in &task.depends_on {
278 let dep_task = find_task_across(active, done, dep_id);
279 match dep_task {
280 Some(t) => {
281 if t.status != TaskStatus::Done && t.status != TaskStatus::Rejected {
282 issues.push(DependencyIssue::NotComplete {
283 id: dep_id.clone(),
284 status: t.status,
285 });
286 }
287 }
288 None => {
289 issues.push(DependencyIssue::Missing { id: dep_id.clone() });
290 }
291 }
292 }
293
294 issues
295}
296
297pub fn is_task_runnable_detailed(
299 task: &Task,
300 active: &QueueFile,
301 done: Option<&QueueFile>,
302 now_rfc3339: &str,
303 include_draft: bool,
304) -> Result<(bool, Vec<NotRunnableReason>)> {
305 let now_dt = crate::timeutil::parse_rfc3339(now_rfc3339)?;
306 let options = RunnableSelectionOptions::new(include_draft, false);
307 let row = analyze_task_runnability(task, active, done, now_rfc3339, now_dt, options);
308 Ok((row.runnable, row.reasons))
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use crate::contracts::{QueueFile, Task, TaskStatus};
315 use std::collections::HashMap;
316
317 fn make_task(
318 id: &str,
319 status: TaskStatus,
320 scheduled_start: Option<&str>,
321 depends_on: Vec<&str>,
322 ) -> Task {
323 Task {
324 id: id.to_string(),
325 status,
326 title: format!("Task {}", id),
327 description: None,
328 priority: Default::default(),
329 tags: vec![],
330 scope: vec![],
331 evidence: vec![],
332 plan: vec![],
333 notes: vec![],
334 request: None,
335 agent: None,
336 created_at: Some("2026-01-18T00:00:00Z".to_string()),
337 updated_at: Some("2026-01-18T00:00:00Z".to_string()),
338 completed_at: None,
339 started_at: None,
340 scheduled_start: scheduled_start.map(|s| s.to_string()),
341 estimated_minutes: None,
342 actual_minutes: None,
343 depends_on: depends_on.into_iter().map(|s| s.to_string()).collect(),
344 blocks: vec![],
345 relates_to: vec![],
346 duplicates: None,
347 custom_fields: HashMap::new(),
348 parent_id: None,
349 }
350 }
351
352 #[test]
353 fn test_runnable_todo_no_deps() {
354 let tasks = vec![make_task("RQ-0001", TaskStatus::Todo, None, vec![])];
355 let active = QueueFile { version: 1, tasks };
356 let now = "2026-01-18T12:00:00Z";
357
358 let report = queue_runnability_report_at(
359 now,
360 &active,
361 None,
362 RunnableSelectionOptions::new(false, false),
363 )
364 .unwrap();
365
366 assert!(report.tasks[0].runnable);
367 assert!(report.tasks[0].reasons.is_empty());
368 assert_eq!(report.summary.runnable_candidates, 1);
369 }
370
371 #[test]
372 fn test_blocked_by_missing_dependency() {
373 let tasks = vec![make_task(
374 "RQ-0001",
375 TaskStatus::Todo,
376 None,
377 vec!["RQ-0002"],
378 )];
379 let active = QueueFile { version: 1, tasks };
380 let now = "2026-01-18T12:00:00Z";
381
382 let report = queue_runnability_report_at(
383 now,
384 &active,
385 None,
386 RunnableSelectionOptions::new(false, false),
387 )
388 .unwrap();
389
390 assert!(!report.tasks[0].runnable);
391 assert_eq!(report.tasks[0].reasons.len(), 1);
392 assert!(matches!(
393 &report.tasks[0].reasons[0],
394 NotRunnableReason::UnmetDependencies { dependencies } if matches!(
395 &dependencies[0],
396 DependencyIssue::Missing { id } if id == "RQ-0002"
397 )
398 ));
399 assert_eq!(report.summary.blocked_by_dependencies, 1);
400 }
401
402 #[test]
403 fn test_blocked_by_incomplete_dependency() {
404 let tasks = vec![
405 make_task("RQ-0001", TaskStatus::Todo, None, vec!["RQ-0002"]),
406 make_task("RQ-0002", TaskStatus::Todo, None, vec![]),
407 ];
408 let active = QueueFile { version: 1, tasks };
409 let now = "2026-01-18T12:00:00Z";
410
411 let report = queue_runnability_report_at(
412 now,
413 &active,
414 None,
415 RunnableSelectionOptions::new(false, false),
416 )
417 .unwrap();
418
419 assert!(!report.tasks[0].runnable);
420 assert!(matches!(
421 &report.tasks[0].reasons[0],
422 NotRunnableReason::UnmetDependencies { dependencies } if matches!(
423 &dependencies[0],
424 DependencyIssue::NotComplete { id, status } if id == "RQ-0002" && *status == TaskStatus::Todo
425 )
426 ));
427 }
428
429 #[test]
430 fn test_blocked_by_future_schedule() {
431 let tasks = vec![make_task(
432 "RQ-0001",
433 TaskStatus::Todo,
434 Some("2026-12-31T00:00:00Z"),
435 vec![],
436 )];
437 let active = QueueFile { version: 1, tasks };
438 let now = "2026-01-18T12:00:00Z";
439
440 let report = queue_runnability_report_at(
441 now,
442 &active,
443 None,
444 RunnableSelectionOptions::new(false, false),
445 )
446 .unwrap();
447
448 assert!(!report.tasks[0].runnable);
449 assert!(matches!(
450 &report.tasks[0].reasons[0],
451 NotRunnableReason::ScheduledStartInFuture { scheduled_start, .. } if scheduled_start == "2026-12-31T00:00:00Z"
452 ));
453 assert_eq!(report.summary.blocked_by_schedule, 1);
454 }
455
456 #[test]
457 fn test_blocked_by_both_deps_and_schedule() {
458 let tasks = vec![make_task(
459 "RQ-0001",
460 TaskStatus::Todo,
461 Some("2026-12-31T00:00:00Z"),
462 vec!["RQ-0002"],
463 )];
464 let active = QueueFile { version: 1, tasks };
465 let now = "2026-01-18T12:00:00Z";
466
467 let report = queue_runnability_report_at(
468 now,
469 &active,
470 None,
471 RunnableSelectionOptions::new(false, false),
472 )
473 .unwrap();
474
475 assert!(!report.tasks[0].runnable);
476 assert_eq!(report.tasks[0].reasons.len(), 2);
477 assert!(matches!(
479 report.tasks[0].reasons[0],
480 NotRunnableReason::UnmetDependencies { .. }
481 ));
482 assert!(matches!(
483 report.tasks[0].reasons[1],
484 NotRunnableReason::ScheduledStartInFuture { .. }
485 ));
486 }
487
488 #[test]
489 fn test_draft_excluded_without_flag() {
490 let tasks = vec![make_task("RQ-0001", TaskStatus::Draft, None, vec![])];
491 let active = QueueFile { version: 1, tasks };
492 let now = "2026-01-18T12:00:00Z";
493
494 let report = queue_runnability_report_at(
495 now,
496 &active,
497 None,
498 RunnableSelectionOptions::new(false, false),
499 )
500 .unwrap();
501
502 assert!(!report.tasks[0].runnable);
503 assert!(matches!(
504 report.tasks[0].reasons[0],
505 NotRunnableReason::DraftExcluded
506 ));
507 assert_eq!(report.summary.blocked_by_status_or_flags, 0);
510 assert_eq!(report.summary.candidates_total, 0);
511 }
512
513 #[test]
514 fn test_draft_included_with_flag() {
515 let tasks = vec![make_task("RQ-0001", TaskStatus::Draft, None, vec![])];
516 let active = QueueFile { version: 1, tasks };
517 let now = "2026-01-18T12:00:00Z";
518
519 let report = queue_runnability_report_at(
520 now,
521 &active,
522 None,
523 RunnableSelectionOptions::new(true, false),
524 )
525 .unwrap();
526
527 assert!(report.tasks[0].runnable);
528 assert!(report.tasks[0].reasons.is_empty());
529 }
530
531 #[test]
532 fn test_draft_blocked_by_schedule_even_with_flag() {
533 let tasks = vec![make_task(
534 "RQ-0001",
535 TaskStatus::Draft,
536 Some("2026-12-31T00:00:00Z"),
537 vec![],
538 )];
539 let active = QueueFile { version: 1, tasks };
540 let now = "2026-01-18T12:00:00Z";
541
542 let report = queue_runnability_report_at(
543 now,
544 &active,
545 None,
546 RunnableSelectionOptions::new(true, false),
547 )
548 .unwrap();
549
550 assert!(!report.tasks[0].runnable);
551 assert!(matches!(
552 report.tasks[0].reasons[0],
553 NotRunnableReason::ScheduledStartInFuture { .. }
554 ));
555 }
556
557 #[test]
558 fn test_done_status_not_runnable() {
559 let tasks = vec![make_task("RQ-0001", TaskStatus::Done, None, vec![])];
560 let active = QueueFile { version: 1, tasks };
561 let now = "2026-01-18T12:00:00Z";
562
563 let report = queue_runnability_report_at(
564 now,
565 &active,
566 None,
567 RunnableSelectionOptions::new(false, false),
568 )
569 .unwrap();
570
571 assert!(!report.tasks[0].runnable);
572 assert!(matches!(
573 report.tasks[0].reasons[0],
574 NotRunnableReason::StatusNotRunnable { status } if status == TaskStatus::Done
575 ));
576 }
577
578 #[test]
579 fn test_rejected_dependency_is_ok() {
580 let done_tasks = vec![make_task("RQ-0002", TaskStatus::Rejected, None, vec![])];
581 let done = QueueFile {
582 version: 1,
583 tasks: done_tasks,
584 };
585 let active_tasks = vec![make_task(
586 "RQ-0001",
587 TaskStatus::Todo,
588 None,
589 vec!["RQ-0002"],
590 )];
591 let active = QueueFile {
592 version: 1,
593 tasks: active_tasks,
594 };
595 let now = "2026-01-18T12:00:00Z";
596
597 let report = queue_runnability_report_at(
598 now,
599 &active,
600 Some(&done),
601 RunnableSelectionOptions::new(false, false),
602 )
603 .unwrap();
604
605 assert!(report.tasks[0].runnable);
606 assert!(report.tasks[0].reasons.is_empty());
607 }
608
609 #[test]
610 fn test_done_dependency_is_ok() {
611 let done_tasks = vec![make_task("RQ-0002", TaskStatus::Done, None, vec![])];
612 let done = QueueFile {
613 version: 1,
614 tasks: done_tasks,
615 };
616 let active_tasks = vec![make_task(
617 "RQ-0001",
618 TaskStatus::Todo,
619 None,
620 vec!["RQ-0002"],
621 )];
622 let active = QueueFile {
623 version: 1,
624 tasks: active_tasks,
625 };
626 let now = "2026-01-18T12:00:00Z";
627
628 let report = queue_runnability_report_at(
629 now,
630 &active,
631 Some(&done),
632 RunnableSelectionOptions::new(false, false),
633 )
634 .unwrap();
635
636 assert!(report.tasks[0].runnable);
637 assert!(report.tasks[0].reasons.is_empty());
638 }
639
640 #[test]
641 fn test_prefer_doing_selects_doing_task() {
642 let tasks = vec![
643 make_task("RQ-0001", TaskStatus::Todo, None, vec![]),
644 make_task("RQ-0002", TaskStatus::Doing, None, vec![]),
645 ];
646 let active = QueueFile { version: 1, tasks };
647 let now = "2026-01-18T12:00:00Z";
648
649 let report = queue_runnability_report_at(
650 now,
651 &active,
652 None,
653 RunnableSelectionOptions::new(false, true),
654 )
655 .unwrap();
656
657 assert_eq!(
658 report.selection.selected_task_id,
659 Some("RQ-0002".to_string())
660 );
661 assert_eq!(
662 report.selection.selected_task_status,
663 Some(TaskStatus::Doing)
664 );
665 }
666
667 #[test]
668 fn test_no_prefer_doing_skips_doing() {
669 let tasks = vec![
670 make_task("RQ-0001", TaskStatus::Todo, None, vec![]),
671 make_task("RQ-0002", TaskStatus::Doing, None, vec![]),
672 ];
673 let active = QueueFile { version: 1, tasks };
674 let now = "2026-01-18T12:00:00Z";
675
676 let report = queue_runnability_report_at(
677 now,
678 &active,
679 None,
680 RunnableSelectionOptions::new(false, false),
681 )
682 .unwrap();
683
684 assert_eq!(
686 report.selection.selected_task_id,
687 Some("RQ-0001".to_string())
688 );
689 assert_eq!(
690 report.selection.selected_task_status,
691 Some(TaskStatus::Todo)
692 );
693 }
694
695 #[test]
696 fn test_summary_counts_correctly() {
697 let tasks = vec![
698 make_task("RQ-0001", TaskStatus::Todo, None, vec![]), make_task(
700 "RQ-0002",
701 TaskStatus::Todo,
702 Some("2026-12-31T00:00:00Z"),
703 vec![],
704 ), make_task("RQ-0003", TaskStatus::Todo, None, vec!["RQ-0009"]), make_task("RQ-0004", TaskStatus::Draft, None, vec![]), make_task("RQ-0005", TaskStatus::Done, None, vec![]), ];
709 let active = QueueFile { version: 1, tasks };
710 let now = "2026-01-18T12:00:00Z";
711
712 let report = queue_runnability_report_at(
713 now,
714 &active,
715 None,
716 RunnableSelectionOptions::new(false, false),
717 )
718 .unwrap();
719
720 assert_eq!(report.summary.total_active, 5);
721 assert_eq!(report.summary.candidates_total, 3); assert_eq!(report.summary.runnable_candidates, 1);
723 assert_eq!(report.summary.blocked_by_dependencies, 1);
724 assert_eq!(report.summary.blocked_by_schedule, 1);
725 assert_eq!(report.summary.blocked_by_status_or_flags, 0); }
727
728 #[test]
729 fn test_report_version_is_stable() {
730 let tasks = vec![make_task("RQ-0001", TaskStatus::Todo, None, vec![])];
731 let active = QueueFile { version: 1, tasks };
732 let now = "2026-01-18T12:00:00Z";
733
734 let report = queue_runnability_report_at(
735 now,
736 &active,
737 None,
738 RunnableSelectionOptions::new(false, false),
739 )
740 .unwrap();
741
742 assert_eq!(report.version, RUNNABILITY_REPORT_VERSION);
743 }
744
745 #[test]
746 fn test_json_serialization_roundtrip() {
747 let tasks = vec![
748 make_task("RQ-0001", TaskStatus::Todo, None, vec!["RQ-0002"]),
749 make_task(
750 "RQ-0002",
751 TaskStatus::Todo,
752 Some("2026-12-31T00:00:00Z"),
753 vec![],
754 ),
755 ];
756 let active = QueueFile { version: 1, tasks };
757 let now = "2026-01-18T12:00:00Z";
758
759 let report = queue_runnability_report_at(
760 now,
761 &active,
762 None,
763 RunnableSelectionOptions::new(false, false),
764 )
765 .unwrap();
766
767 let json = serde_json::to_string(&report).unwrap();
768 assert!(json.contains("\"version\":"));
769 assert!(json.contains("\"now\":\"2026-01-18T12:00:00Z\""));
770 assert!(json.contains("\"runnable\":false"));
771 }
772}