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 =
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
65/// Check if a specific task is runnable (convenience wrapper).
66pub 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}