ralph/queue/operations/runnability/
report.rs1use anyhow::Result;
17
18use crate::contracts::{BlockingState, QueueFile, Task, TaskStatus};
19use crate::queue::operations::RunnableSelectionOptions;
20
21use super::analysis::analyze_task_runnability;
22use super::model::{
23 NotRunnableReason, QueueRunnabilityReport, QueueRunnabilitySelection, QueueRunnabilitySummary,
24 RUNNABILITY_REPORT_VERSION,
25};
26
27pub fn queue_runnability_report(
29 active: &QueueFile,
30 done: Option<&QueueFile>,
31 options: RunnableSelectionOptions,
32) -> Result<QueueRunnabilityReport> {
33 let now = crate::timeutil::now_utc_rfc3339()?;
34 queue_runnability_report_at(&now, active, done, options)
35}
36
37pub fn queue_runnability_report_at(
39 now_rfc3339: &str,
40 active: &QueueFile,
41 done: Option<&QueueFile>,
42 options: RunnableSelectionOptions,
43) -> Result<QueueRunnabilityReport> {
44 let now_dt = crate::timeutil::parse_rfc3339(now_rfc3339)?;
45 let tasks = active
46 .tasks
47 .iter()
48 .map(|task| analyze_task_runnability(task, active, done, now_rfc3339, now_dt, options))
49 .collect::<Vec<_>>();
50
51 let mut summary = summarize_rows(active.tasks.len(), &tasks, options);
52 summary.blocking = derive_queue_blocking_state(&tasks, &summary, options.include_draft);
53 let selection = build_selection(active, &tasks, options);
54
55 Ok(QueueRunnabilityReport {
56 version: RUNNABILITY_REPORT_VERSION,
57 now: now_rfc3339.to_string(),
58 selection,
59 summary,
60 tasks,
61 })
62}
63
64pub fn is_task_runnable_detailed(
66 task: &Task,
67 active: &QueueFile,
68 done: Option<&QueueFile>,
69 now_rfc3339: &str,
70 include_draft: bool,
71) -> Result<(bool, Vec<NotRunnableReason>)> {
72 let now_dt = crate::timeutil::parse_rfc3339(now_rfc3339)?;
73 let options = RunnableSelectionOptions::new(include_draft, false);
74 let row = analyze_task_runnability(task, active, done, now_rfc3339, now_dt, options);
75 Ok((row.runnable, row.reasons))
76}
77
78fn summarize_rows(
79 total_active: usize,
80 rows: &[super::model::TaskRunnabilityRow],
81 options: RunnableSelectionOptions,
82) -> QueueRunnabilitySummary {
83 let mut candidates_total = 0usize;
84 let mut runnable_candidates = 0usize;
85 let mut blocked_by_dependencies = 0usize;
86 let mut blocked_by_schedule = 0usize;
87 let mut blocked_by_status_or_flags = 0usize;
88
89 for row in rows.iter().filter(|row| is_candidate(row.status, options)) {
90 candidates_total += 1;
91 if row.runnable {
92 runnable_candidates += 1;
93 continue;
94 }
95
96 for reason in &row.reasons {
97 match reason {
98 NotRunnableReason::StatusNotRunnable { .. } | NotRunnableReason::DraftExcluded => {
99 blocked_by_status_or_flags += 1;
100 }
101 NotRunnableReason::UnmetDependencies { .. } => blocked_by_dependencies += 1,
102 NotRunnableReason::ScheduledStartInFuture { .. } => blocked_by_schedule += 1,
103 }
104 }
105 }
106
107 QueueRunnabilitySummary {
108 total_active,
109 candidates_total,
110 runnable_candidates,
111 blocked_by_dependencies,
112 blocked_by_schedule,
113 blocked_by_status_or_flags,
114 blocking: None,
115 }
116}
117
118fn derive_queue_blocking_state(
119 rows: &[super::model::TaskRunnabilityRow],
120 summary: &QueueRunnabilitySummary,
121 include_draft: bool,
122) -> Option<BlockingState> {
123 if summary.runnable_candidates > 0 {
124 return None;
125 }
126
127 if summary.candidates_total == 0 {
128 return Some(BlockingState::idle(include_draft));
129 }
130
131 let next_schedule = rows
132 .iter()
133 .flat_map(|row| row.reasons.iter())
134 .filter_map(|reason| match reason {
135 NotRunnableReason::ScheduledStartInFuture {
136 scheduled_start,
137 seconds_until_runnable,
138 ..
139 } => Some((scheduled_start.clone(), *seconds_until_runnable)),
140 _ => None,
141 })
142 .min_by_key(|(_, seconds)| *seconds);
143
144 match (
145 summary.blocked_by_dependencies > 0,
146 summary.blocked_by_schedule > 0,
147 ) {
148 (true, false) => Some(BlockingState::dependency_blocked(
149 summary.blocked_by_dependencies,
150 )),
151 (false, true) => Some(BlockingState::schedule_blocked(
152 summary.blocked_by_schedule,
153 next_schedule.as_ref().map(|(at, _)| at.clone()),
154 next_schedule.as_ref().map(|(_, seconds)| *seconds),
155 )),
156 (true, true) => Some(BlockingState::mixed_queue(
157 summary.blocked_by_dependencies,
158 summary.blocked_by_schedule,
159 summary.blocked_by_status_or_flags,
160 )),
161 (false, false) => Some(BlockingState::idle(include_draft)),
162 }
163}
164
165fn build_selection(
166 active: &QueueFile,
167 rows: &[super::model::TaskRunnabilityRow],
168 options: RunnableSelectionOptions,
169) -> QueueRunnabilitySelection {
170 let (selected_task_id, selected_task_status) = if options.prefer_doing
171 && let Some(task) = active.tasks.iter().find(|t| t.status == TaskStatus::Doing)
172 {
173 (Some(task.id.clone()), Some(TaskStatus::Doing))
174 } else {
175 select_first_runnable_row(rows, options)
176 .map(|row| (Some(row.id.clone()), Some(row.status)))
177 .unwrap_or((None, None))
178 };
179
180 QueueRunnabilitySelection {
181 include_draft: options.include_draft,
182 prefer_doing: options.prefer_doing,
183 selected_task_id,
184 selected_task_status,
185 }
186}
187
188fn select_first_runnable_row(
189 rows: &[super::model::TaskRunnabilityRow],
190 options: RunnableSelectionOptions,
191) -> Option<&super::model::TaskRunnabilityRow> {
192 rows.iter().find(|row| {
193 row.runnable
194 && (row.status == TaskStatus::Todo
195 || (options.include_draft && row.status == TaskStatus::Draft))
196 })
197}
198
199fn is_candidate(status: TaskStatus, options: RunnableSelectionOptions) -> bool {
200 status == TaskStatus::Todo || (options.include_draft && status == TaskStatus::Draft)
201}