1use 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#[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 #[arg(long, value_enum)]
50 pub status: Vec<StatusArg>,
51
52 #[arg(long)]
54 pub tag: Vec<String>,
55
56 #[arg(long)]
58 pub scope: Vec<String>,
59
60 #[arg(long)]
62 pub filter_deps: Option<String>,
63
64 #[arg(long)]
66 pub include_done: bool,
67
68 #[arg(long)]
70 pub only_done: bool,
71
72 #[arg(long, value_enum, default_value_t = QueueListFormat::Compact)]
74 pub format: QueueListFormat,
75
76 #[arg(long, default_value_t = 50)]
78 pub limit: u32,
79
80 #[arg(long)]
82 pub all: bool,
83
84 #[arg(long, value_enum)]
87 pub sort_by: Option<QueueListSortBy>,
88
89 #[arg(long, value_enum, default_value_t = QueueSortOrder::Descending)]
91 pub order: QueueSortOrder,
92
93 #[arg(long, short)]
95 pub quiet: bool,
96
97 #[arg(long)]
99 pub scheduled: bool,
100
101 #[arg(long, value_name = "TIMESTAMP")]
103 pub scheduled_after: Option<String>,
104
105 #[arg(long, value_name = "TIMESTAMP")]
107 pub scheduled_before: Option<String>,
108
109 #[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 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 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 let tasks: Vec<&Task> = tasks
183 .into_iter()
184 .filter(|t| {
185 if args.scheduled && t.scheduled_start.is_none() {
187 return false;
188 }
189
190 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 return false;
203 }
204 }
205
206 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 return false;
219 }
220 }
221
222 true
223 })
224 .collect();
225
226 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 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 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 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}