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