Skip to main content

ralph/cli/queue/
list.rs

1//! Queue list subcommand.
2//!
3//! Responsibilities:
4//! - List tasks from queue and done archive with various filters.
5//! - Support status, tag, scope, dependency, and scheduled time filters.
6//! - Output in compact, long, or JSON formats.
7//! - Optionally display ETA estimates from execution history.
8//!
9//! Not handled here:
10//! - Task creation, modification, or deletion (see other queue subcommands).
11//! - Content-based search (see `search.rs`).
12//! - Complex reporting or aggregation (see `reports` module).
13//! - Real-time progress tracking (handled by external UI clients).
14//!
15//! Invariants/assumptions:
16//! - Queue files are loaded and validated before filtering.
17//! - Output ordering:
18//!   - Default: active queue tasks are emitted in queue file order.
19//!   - With --include-done: done tasks are appended after active tasks.
20//!   - With --sort-by: tasks are sorted by the requested field (mixing statuses as needed).
21//! - ETA is based on execution history only; missing history shows "n/a".
22//! - ETA column is only added to text formats (compact/long), not JSON.
23//! - Scheduled filters use RFC3339 or relative time expressions.
24//! - Sorting is stable: all comparisons end with id tie-breaker for deterministic output.
25//! - Missing/invalid timestamps sort last regardless of sort order (known before unknown).
26
27use std::cmp::Ordering;
28
29use anyhow::{Result, bail};
30use clap::Args;
31use time::OffsetDateTime;
32
33use crate::cli::queue::shared::task_eta_display;
34use crate::cli::{load_and_validate_queues_read_only, resolve_list_limit};
35use crate::config::Resolved;
36use crate::contracts::{Task, TaskStatus};
37use crate::eta_calculator::EtaCalculator;
38use crate::{outpututil, queue};
39
40use super::{QueueListFormat, QueueListSortBy, QueueSortOrder, StatusArg};
41
42/// Arguments for `ralph queue list`.
43#[derive(Args)]
44#[command(
45    after_long_help = "Examples:\n  ralph queue list\n  ralph queue list --status todo --tag rust\n  ralph queue list --status doing --scope crates/ralph\n  ralph queue list --include-done --limit 20\n  ralph queue list --only-done --all\n  ralph queue list --filter-deps=RQ-0100\n  ralph queue list --format json\n  ralph queue list --format json | jq '.[] | select(.status == \"todo\")'\n  ralph queue list --scheduled\n  ralph queue list --scheduled-after '2026-01-01T00:00:00Z'\n  ralph queue list --scheduled-before '+7d'\n  ralph queue list --with-eta\n  ralph queue list --with-eta --format long\n  ralph queue list --sort-by updated_at\n  ralph queue list --scheduled --sort-by scheduled_start --order ascending\n  ralph queue list --scheduled-after '+0d' --sort-by scheduled_start --order ascending"
46)]
47pub struct QueueListArgs {
48    /// Filter by status (repeatable).
49    #[arg(long, value_enum)]
50    pub status: Vec<StatusArg>,
51
52    /// Filter by tag (repeatable, case-insensitive).
53    #[arg(long)]
54    pub tag: Vec<String>,
55
56    /// Filter by scope token (repeatable, case-insensitive; substring match).
57    #[arg(long)]
58    pub scope: Vec<String>,
59
60    /// Filter by tasks that depend on the given task ID (recursively).
61    #[arg(long)]
62    pub filter_deps: Option<String>,
63
64    /// Include tasks from .ralph/done.jsonc after active queue output.
65    #[arg(long)]
66    pub include_done: bool,
67
68    /// Only list tasks from .ralph/done.jsonc (ignores active queue).
69    #[arg(long)]
70    pub only_done: bool,
71
72    /// Output format.
73    #[arg(long, value_enum, default_value_t = QueueListFormat::Compact)]
74    pub format: QueueListFormat,
75
76    /// Maximum tasks to show (0 = no limit).
77    #[arg(long, default_value_t = 50)]
78    pub limit: u32,
79
80    /// Show all tasks (ignores --limit).
81    #[arg(long)]
82    pub all: bool,
83
84    /// Sort by field (supported: priority, created_at, updated_at, started_at, scheduled_start, status, title).
85    /// Missing/invalid timestamps sort last regardless of order.
86    #[arg(long, value_enum)]
87    pub sort_by: Option<QueueListSortBy>,
88
89    /// Sort order (default: descending).
90    #[arg(long, value_enum, default_value_t = QueueSortOrder::Descending)]
91    pub order: QueueSortOrder,
92
93    /// Suppress size warning output.
94    #[arg(long, short)]
95    pub quiet: bool,
96
97    /// Filter to only show scheduled tasks (have scheduled_start set).
98    #[arg(long)]
99    pub scheduled: bool,
100
101    /// Filter tasks scheduled after this time (RFC3339 or relative expression).
102    #[arg(long, value_name = "TIMESTAMP")]
103    pub scheduled_after: Option<String>,
104
105    /// Filter tasks scheduled before this time (RFC3339 or relative expression).
106    #[arg(long, value_name = "TIMESTAMP")]
107    pub scheduled_before: Option<String>,
108
109    /// Include an execution-history-based ETA estimate column (text formats only).
110    #[arg(long)]
111    pub with_eta: bool,
112}
113
114pub(crate) fn handle(resolved: &Resolved, args: QueueListArgs) -> Result<()> {
115    if args.include_done && args.only_done {
116        bail!(
117            "Conflicting flags: --include-done and --only-done are mutually exclusive. Choose either to include done tasks or to only show done tasks."
118        );
119    }
120
121    let (queue_file, done_file) =
122        load_and_validate_queues_read_only(resolved, args.include_done || args.only_done)?;
123
124    // Check queue size and print warning if needed
125    if !args.quiet {
126        let size_threshold =
127            queue::size_threshold_or_default(resolved.config.queue.size_warning_threshold_kb);
128        let count_threshold =
129            queue::count_threshold_or_default(resolved.config.queue.task_count_warning_threshold);
130        if let Ok(result) = queue::check_queue_size(
131            &resolved.queue_path,
132            queue_file.tasks.len(),
133            size_threshold,
134            count_threshold,
135        ) {
136            queue::print_size_warning_if_needed(&result, args.quiet);
137        }
138    }
139    let done_ref = done_file
140        .as_ref()
141        .filter(|d| !d.tasks.is_empty() || resolved.done_path.exists());
142
143    let statuses: Vec<TaskStatus> = args.status.into_iter().map(|s| s.into()).collect();
144    let limit = resolve_list_limit(args.limit, args.all);
145
146    let mut tasks: Vec<&Task> = Vec::new();
147    if !args.only_done {
148        tasks.extend(queue::filter_tasks(
149            &queue_file,
150            &statuses,
151            &args.tag,
152            &args.scope,
153            None,
154        ));
155    }
156    if (args.include_done || args.only_done)
157        && let Some(done_ref) = done_ref
158    {
159        tasks.extend(queue::filter_tasks(
160            done_ref,
161            &statuses,
162            &args.tag,
163            &args.scope,
164            None,
165        ));
166    }
167
168    // Apply dependency filter if specified
169    let tasks = if let Some(ref root_id) = args.filter_deps {
170        let dependents_list = queue::get_dependents(root_id, &queue_file, done_ref);
171        let dependents: std::collections::HashSet<&str> =
172            dependents_list.iter().map(|s| s.as_str()).collect();
173        tasks
174            .into_iter()
175            .filter(|t| dependents.contains(t.id.trim()))
176            .collect()
177    } else {
178        tasks
179    };
180
181    // Apply scheduling filters
182    let tasks: Vec<&Task> = tasks
183        .into_iter()
184        .filter(|t| {
185            // --scheduled flag: only show tasks with scheduled_start set
186            if args.scheduled && t.scheduled_start.is_none() {
187                return false;
188            }
189
190            // --scheduled-after filter
191            if let Some(ref after) = args.scheduled_after {
192                if let Some(ref scheduled) = t.scheduled_start {
193                    if let Ok(scheduled_dt) = crate::timeutil::parse_rfc3339(scheduled)
194                        && let Ok(after_dt) = crate::timeutil::parse_relative_time(after)
195                            .and_then(|s| crate::timeutil::parse_rfc3339(&s))
196                        && scheduled_dt <= after_dt
197                    {
198                        return false;
199                    }
200                } else {
201                    // Task has no scheduled_start, so it doesn't satisfy "after" filter
202                    return false;
203                }
204            }
205
206            // --scheduled-before filter
207            if let Some(ref before) = args.scheduled_before {
208                if let Some(ref scheduled) = t.scheduled_start {
209                    if let Ok(scheduled_dt) = crate::timeutil::parse_rfc3339(scheduled)
210                        && let Ok(before_dt) = crate::timeutil::parse_relative_time(before)
211                            .and_then(|s| crate::timeutil::parse_rfc3339(&s))
212                        && scheduled_dt >= before_dt
213                    {
214                        return false;
215                    }
216                } else {
217                    // Task has no scheduled_start, so it doesn't satisfy "before" filter
218                    return false;
219                }
220            }
221
222            true
223        })
224        .collect();
225
226    // Apply sort if specified
227    let tasks = if let Some(sort_by) = args.sort_by {
228        let descending = args.order.is_descending();
229        let mut sorted = tasks;
230        sorted.sort_by(|a, b| {
231            let ord = match sort_by {
232                QueueListSortBy::Priority => {
233                    let ord = a.priority.cmp(&b.priority);
234                    if descending { ord.reverse() } else { ord }
235                }
236                QueueListSortBy::CreatedAt => cmp_optional_rfc3339_missing_last(
237                    a.created_at.as_deref(),
238                    b.created_at.as_deref(),
239                    descending,
240                ),
241                QueueListSortBy::UpdatedAt => cmp_optional_rfc3339_missing_last(
242                    a.updated_at.as_deref(),
243                    b.updated_at.as_deref(),
244                    descending,
245                ),
246                QueueListSortBy::StartedAt => cmp_optional_rfc3339_missing_last(
247                    a.started_at.as_deref(),
248                    b.started_at.as_deref(),
249                    descending,
250                ),
251                QueueListSortBy::ScheduledStart => cmp_optional_rfc3339_missing_last(
252                    a.scheduled_start.as_deref(),
253                    b.scheduled_start.as_deref(),
254                    descending,
255                ),
256                QueueListSortBy::Status => {
257                    let ord = status_rank(a.status).cmp(&status_rank(b.status));
258                    if descending { ord.reverse() } else { ord }
259                }
260                QueueListSortBy::Title => {
261                    let ord = cmp_ascii_case_insensitive(&a.title, &b.title)
262                        .then_with(|| a.title.cmp(&b.title));
263                    if descending { ord.reverse() } else { ord }
264                }
265            };
266
267            ord.then_with(|| a.id.cmp(&b.id))
268        });
269        sorted
270    } else {
271        tasks
272    };
273
274    // Helper functions for sorting
275    fn cmp_optional_rfc3339_missing_last(
276        a: Option<&str>,
277        b: Option<&str>,
278        descending: bool,
279    ) -> Ordering {
280        let a_dt: Option<OffsetDateTime> = a.and_then(crate::timeutil::parse_rfc3339_opt);
281        let b_dt: Option<OffsetDateTime> = b.and_then(crate::timeutil::parse_rfc3339_opt);
282
283        match (a_dt, b_dt) {
284            (Some(a_dt), Some(b_dt)) => {
285                let ord = a_dt.cmp(&b_dt);
286                if descending { ord.reverse() } else { ord }
287            }
288            (Some(_), None) => Ordering::Less,
289            (None, Some(_)) => Ordering::Greater,
290            (None, None) => Ordering::Equal,
291        }
292    }
293
294    fn status_rank(s: TaskStatus) -> u8 {
295        match s {
296            TaskStatus::Draft => 0,
297            TaskStatus::Todo => 1,
298            TaskStatus::Doing => 2,
299            TaskStatus::Done => 3,
300            TaskStatus::Rejected => 4,
301        }
302    }
303
304    fn cmp_ascii_case_insensitive(a: &str, b: &str) -> Ordering {
305        a.bytes()
306            .map(|c| c.to_ascii_lowercase())
307            .cmp(b.bytes().map(|c| c.to_ascii_lowercase()))
308    }
309
310    let max = limit.unwrap_or(usize::MAX);
311    let tasks: Vec<&Task> = tasks.into_iter().take(max).collect();
312
313    // Load ETA calculator if needed (only for text formats)
314    let eta_calculator = if args.with_eta && args.format != QueueListFormat::Json {
315        let cache_dir = resolved.repo_root.join(".ralph/cache");
316        Some(EtaCalculator::load(&cache_dir))
317    } else {
318        None
319    };
320
321    match args.format {
322        QueueListFormat::Compact => {
323            for task in tasks {
324                let base = outpututil::format_task_compact(task);
325                if let Some(ref calc) = eta_calculator {
326                    let eta = task_eta_display(resolved, calc, task);
327                    println!("{}\t{}", base, eta);
328                } else {
329                    println!("{}", base);
330                }
331            }
332        }
333        QueueListFormat::Long => {
334            for task in tasks {
335                let base = outpututil::format_task_detailed(task);
336                if let Some(ref calc) = eta_calculator {
337                    let eta = task_eta_display(resolved, calc, task);
338                    println!("{}\t{}", base, eta);
339                } else {
340                    println!("{}", base);
341                }
342            }
343        }
344        QueueListFormat::Json => {
345            // JSON format ignores --with-eta per design
346            let owned_tasks: Vec<Task> = tasks.into_iter().cloned().collect();
347            let json = serde_json::to_string_pretty(&owned_tasks)?;
348            println!("{json}");
349        }
350    }
351
352    Ok(())
353}