Skip to main content

ralph/queue/operations/
runnability.rs

1//! Runnability analysis for queue tasks.
2//!
3//! Responsibilities:
4//! - Determine why tasks are or aren't runnable (status, dependencies, schedule).
5//! - Provide structured reports for CLI introspection (`queue explain`, `run --dry-run`).
6//!
7//! Does not handle:
8//! - Task selection or execution (see `crate::queue::operations::query`).
9//! - Queue persistence or locking.
10//!
11//! Invariants/assumptions:
12//! - Report generation is deterministic for a given `now` timestamp.
13//! - Reasons are ordered: status/flags → dependencies → schedule.
14
15use crate::contracts::{QueueFile, Task, TaskStatus};
16use crate::queue::operations::{RunnableSelectionOptions, find_task_across};
17use anyhow::Result;
18use serde::Serialize;
19
20/// Report version for JSON stability.
21pub const RUNNABILITY_REPORT_VERSION: u32 = 1;
22
23/// A structured report of queue runnability.
24#[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/// Selection context for the report.
35#[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/// Summary counts of runnability states.
45#[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/// Per-task runnability row.
57#[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/// Reason a task is not runnable.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68#[serde(tag = "kind", rename_all = "snake_case")]
69pub enum NotRunnableReason {
70    /// Status prevents running (Done/Rejected).
71    StatusNotRunnable { status: TaskStatus },
72    /// Draft excluded because include_draft is false.
73    DraftExcluded,
74    /// Dependencies are not met.
75    UnmetDependencies { dependencies: Vec<DependencyIssue> },
76    /// Scheduled start is in the future.
77    ScheduledStartInFuture {
78        scheduled_start: String,
79        now: String,
80        seconds_until_runnable: i64,
81    },
82}
83
84/// Specific dependency issue.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
86#[serde(tag = "kind", rename_all = "snake_case")]
87pub enum DependencyIssue {
88    /// Dependency task not found.
89    Missing { id: String },
90    /// Dependency task exists but is not Done/Rejected.
91    NotComplete { id: String, status: TaskStatus },
92}
93
94/// Build a runnability report with the current time.
95pub 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
104/// Build a runnability report with a specific timestamp (deterministic for tests).
105pub 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    // Determine selected task
121    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        // Count candidates (Todo or Draft if include_draft)
128        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                // Count blockers
137                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    // Find selected task based on options (same logic as select_runnable_task_index)
158    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
203/// Analyze a single task's runnability.
204fn 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    // 1. Status/flags check
216    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    // 2. Dependency check (only if not already blocked by status)
233    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    // 3. Schedule check (only if not already blocked)
244    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
269/// Check dependencies and return issues.
270fn 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
297/// Check if a specific task is runnable (convenience wrapper).
298pub 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        // Order: deps first, then schedule
478        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        // DraftExcluded is not counted in summary because draft tasks are not candidates
508        // when include_draft=false
509        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        // Without prefer_doing, selects first runnable Todo
685        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![]), // runnable
699            make_task(
700                "RQ-0002",
701                TaskStatus::Todo,
702                Some("2026-12-31T00:00:00Z"),
703                vec![],
704            ), // blocked by schedule
705            make_task("RQ-0003", TaskStatus::Todo, None, vec!["RQ-0009"]), // blocked by deps
706            make_task("RQ-0004", TaskStatus::Draft, None, vec![]), // not a candidate (draft excluded)
707            make_task("RQ-0005", TaskStatus::Done, None, vec![]),  // not a candidate (done)
708        ];
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); // Todo only (RQ-0001, 2, 3)
722        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); // Draft is not a candidate
726    }
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}