Skip to main content

ralph/queue/operations/runnability/
report.rs

1//! Full runnability report assembly and selection.
2//!
3//! Responsibilities:
4//! - Build queue-wide runnability reports and summary counts.
5//! - Choose the selected task reported to callers using existing queue semantics.
6//! - Provide the task-level convenience wrapper for detailed runnability checks.
7//!
8//! Does not handle:
9//! - Low-level blocker analysis details.
10//! - Queue persistence or locking.
11//!
12//! Invariants/assumptions:
13//! - Candidate counting matches `queue next` semantics: Todo plus Draft when enabled.
14//! - Prefer-doing selection intentionally wins even if a Doing task is blocked.
15
16use 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
27/// Build a runnability report with the current time.
28pub 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
37/// Build a runnability report with a specific timestamp (deterministic for tests).
38pub 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
64/// Check if a specific task is runnable (convenience wrapper).
65pub 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}