1use std::collections::HashSet;
20
21use anyhow::{Result, bail};
22use clap::Args;
23
24use crate::config::Resolved;
25use crate::constants::limits::MAX_COUNT;
26use crate::contracts::TaskStatus;
27use crate::queue;
28use crate::queue::validation;
29
30#[derive(Args)]
31pub struct QueueNextIdArgs {
32 #[arg(short = 'n', long, default_value = "1", value_name = "COUNT")]
34 pub count: usize,
35}
36
37pub(crate) fn handle(resolved: &Resolved, args: QueueNextIdArgs) -> Result<()> {
38 if args.count == 0 {
40 bail!("Count must be at least 1");
41 }
42 if args.count > MAX_COUNT {
43 bail!(
44 "Count cannot exceed {} (requested: {})",
45 MAX_COUNT,
46 args.count
47 );
48 }
49
50 let queue_file = queue::load_queue_or_default(&resolved.queue_path)?;
52 let done_file = queue::load_queue_or_default(&resolved.done_path)?;
53
54 let expected_prefix = queue::normalize_prefix(&resolved.id_prefix);
56 let mut seen_ids = HashSet::new();
57 let mut duplicates = Vec::new();
58 let mut max_value: u32 = 0;
59
60 for (idx, task) in queue_file.tasks.iter().enumerate() {
62 match validation::validate_task_id(idx, &task.id, &expected_prefix, resolved.id_width) {
63 Ok(value) => {
64 if task.status != TaskStatus::Rejected && value > max_value {
65 max_value = value;
66 }
67 if !seen_ids.insert(task.id.clone()) {
68 duplicates.push(task.id.clone());
69 }
70 }
71 Err(e) => {
72 log::warn!("Invalid task ID in queue: {}", e);
73 }
74 }
75 }
76
77 for (idx, task) in done_file.tasks.iter().enumerate() {
79 match validation::validate_task_id(idx, &task.id, &expected_prefix, resolved.id_width) {
80 Ok(value) => {
81 if task.status != TaskStatus::Rejected && value > max_value {
82 max_value = value;
83 }
84 if !seen_ids.insert(task.id.clone()) {
85 duplicates.push(task.id.clone());
86 }
87 }
88 Err(e) => {
89 log::warn!("Invalid task ID in done: {}", e);
90 }
91 }
92 }
93
94 if !duplicates.is_empty() {
96 log::warn!("Duplicate task IDs detected: {:?}", duplicates);
97 eprintln!(
98 "Warning: Found duplicate task IDs: {}",
99 duplicates.join(", ")
100 );
101 }
102
103 let next_value = max_value.saturating_add(1);
104 let first_id = queue::format_id(&expected_prefix, next_value, resolved.id_width);
105
106 let prefix_len = resolved.id_prefix.len() + 1; let first_num: u32 = first_id[prefix_len..].parse()?;
109
110 for i in 0..args.count {
112 let num = first_num + i as u32;
113 let id = format!(
114 "{}-{:0width$}",
115 resolved.id_prefix,
116 num,
117 width = resolved.id_width
118 );
119 println!("{id}");
120 }
121
122 Ok(())
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use crate::contracts::{QueueFile, Task, TaskStatus};
129 use std::collections::HashMap;
130 use tempfile::TempDir;
131
132 fn task(id: &str, status: TaskStatus) -> Task {
133 Task {
134 id: id.to_string(),
135 status,
136 title: "Test task".to_string(),
137 description: None,
138 priority: Default::default(),
139 tags: vec![],
140 scope: vec![],
141 evidence: vec![],
142 plan: vec![],
143 notes: vec![],
144 request: None,
145 agent: None,
146 created_at: Some("2026-01-18T00:00:00Z".to_string()),
147 updated_at: Some("2026-01-18T00:00:00Z".to_string()),
148 completed_at: None,
149 started_at: None,
150 scheduled_start: None,
151 estimated_minutes: None,
152 actual_minutes: None,
153 depends_on: vec![],
154 blocks: vec![],
155 relates_to: vec![],
156 duplicates: None,
157 custom_fields: HashMap::new(),
158 parent_id: None,
159 }
160 }
161
162 fn setup_test_queue(temp: &TempDir, tasks: Vec<Task>) -> Resolved {
163 let repo_root = temp.path();
164 let ralph_dir = repo_root.join(".ralph");
165 std::fs::create_dir_all(&ralph_dir).unwrap();
166
167 let queue_path = ralph_dir.join("queue.json");
168 let done_path = ralph_dir.join("done.json");
169
170 let queue_file = QueueFile { version: 1, tasks };
171 let queue_json = serde_json::to_string_pretty(&queue_file).unwrap();
172 std::fs::write(&queue_path, queue_json).unwrap();
173
174 let done_file = QueueFile {
176 version: 1,
177 tasks: vec![],
178 };
179 let done_json = serde_json::to_string_pretty(&done_file).unwrap();
180 std::fs::write(&done_path, done_json).unwrap();
181
182 Resolved {
183 config: crate::contracts::Config::default(),
184 repo_root: repo_root.to_path_buf(),
185 queue_path,
186 done_path,
187 id_prefix: "RQ".to_string(),
188 id_width: 4,
189 global_config_path: None,
190 project_config_path: None,
191 }
192 }
193
194 #[test]
195 fn test_count_validation_zero() {
196 let temp = TempDir::new().unwrap();
197 let resolved = setup_test_queue(&temp, vec![]);
198
199 let args = QueueNextIdArgs { count: 0 };
200 let result = handle(&resolved, args);
201 assert!(result.is_err());
202 let err = result.unwrap_err().to_string();
203 assert_eq!(err, "Count must be at least 1");
204 }
205
206 #[test]
207 fn test_count_validation_max() {
208 let temp = TempDir::new().unwrap();
209 let resolved = setup_test_queue(&temp, vec![]);
210
211 let args = QueueNextIdArgs { count: 101 };
212 let result = handle(&resolved, args);
213 assert!(result.is_err());
214 let err = result.unwrap_err().to_string();
215 assert_eq!(err, "Count cannot exceed 100 (requested: 101)");
216 }
217
218 #[test]
219 fn test_min_count_boundary() {
220 let temp = TempDir::new().unwrap();
221 let resolved = setup_test_queue(&temp, vec![]);
222
223 let args = QueueNextIdArgs { count: 1 };
225 let result = handle(&resolved, args);
226 assert!(result.is_ok());
227 }
228
229 #[test]
230 fn test_single_id_runs_successfully() {
231 let temp = TempDir::new().unwrap();
232 let resolved = setup_test_queue(&temp, vec![task("RQ-0001", TaskStatus::Todo)]);
233
234 let args = QueueNextIdArgs { count: 1 };
235 let result = handle(&resolved, args);
236 assert!(result.is_ok());
237 }
238
239 #[test]
240 fn test_multiple_ids_runs_successfully() {
241 let temp = TempDir::new().unwrap();
242 let resolved = setup_test_queue(&temp, vec![task("RQ-0005", TaskStatus::Todo)]);
243
244 let args = QueueNextIdArgs { count: 3 };
245 let result = handle(&resolved, args);
246 assert!(result.is_ok());
247 }
248
249 #[test]
250 fn test_empty_queue_generates_from_one() {
251 let temp = TempDir::new().unwrap();
252 let resolved = setup_test_queue(&temp, vec![]);
253
254 let args = QueueNextIdArgs { count: 1 };
255 let result = handle(&resolved, args);
256 assert!(result.is_ok());
257 }
258
259 #[test]
260 fn test_max_count_boundary() {
261 let temp = TempDir::new().unwrap();
262 let resolved = setup_test_queue(&temp, vec![]);
263
264 let args = QueueNextIdArgs { count: 100 };
266 let result = handle(&resolved, args);
267 assert!(result.is_ok());
268 }
269
270 fn setup_test_queues(
271 temp: &TempDir,
272 queue_tasks: Vec<Task>,
273 done_tasks: Vec<Task>,
274 ) -> Resolved {
275 let repo_root = temp.path();
276 let ralph_dir = repo_root.join(".ralph");
277 std::fs::create_dir_all(&ralph_dir).unwrap();
278
279 let queue_path = ralph_dir.join("queue.json");
280 let done_path = ralph_dir.join("done.json");
281
282 let queue_file = QueueFile {
283 version: 1,
284 tasks: queue_tasks,
285 };
286 let queue_json = serde_json::to_string_pretty(&queue_file).unwrap();
287 std::fs::write(&queue_path, queue_json).unwrap();
288
289 let done_file = QueueFile {
290 version: 1,
291 tasks: done_tasks,
292 };
293 let done_json = serde_json::to_string_pretty(&done_file).unwrap();
294 std::fs::write(&done_path, done_json).unwrap();
295
296 Resolved {
297 config: crate::contracts::Config::default(),
298 repo_root: repo_root.to_path_buf(),
299 queue_path,
300 done_path,
301 id_prefix: "RQ".to_string(),
302 id_width: 4,
303 global_config_path: None,
304 project_config_path: None,
305 }
306 }
307
308 #[test]
309 fn test_duplicate_ids_in_queue_returns_next_id() {
310 let temp = TempDir::new().unwrap();
311 let queue_tasks = vec![
313 task("RQ-0001", TaskStatus::Todo),
314 task("RQ-0002", TaskStatus::Todo),
315 task("RQ-0001", TaskStatus::Todo), ];
317 let resolved = setup_test_queues(&temp, queue_tasks, vec![]);
318
319 let args = QueueNextIdArgs { count: 1 };
320 let result = handle(&resolved, args);
321
322 assert!(result.is_ok());
324 }
325
326 #[test]
327 fn test_duplicate_ids_across_queue_and_done_returns_next_id() {
328 let temp = TempDir::new().unwrap();
329 let queue_tasks = vec![task("RQ-0001", TaskStatus::Todo)];
331 let done_tasks = vec![
332 task("RQ-0001", TaskStatus::Done), task("RQ-0005", TaskStatus::Done),
334 ];
335 let resolved = setup_test_queues(&temp, queue_tasks, done_tasks);
336
337 let args = QueueNextIdArgs { count: 1 };
338 let result = handle(&resolved, args);
339
340 assert!(result.is_ok());
342 }
343
344 #[test]
345 fn test_multiple_duplicates_returns_correct_next_id() {
346 let temp = TempDir::new().unwrap();
347 let queue_tasks = vec![
349 task("RQ-0001", TaskStatus::Todo),
350 task("RQ-0002", TaskStatus::Todo),
351 task("RQ-0001", TaskStatus::Todo), task("RQ-0003", TaskStatus::Todo),
353 task("RQ-0002", TaskStatus::Todo), ];
355 let resolved = setup_test_queues(&temp, queue_tasks, vec![]);
356
357 let args = QueueNextIdArgs { count: 1 };
358 let result = handle(&resolved, args);
359
360 assert!(result.is_ok());
362 }
363
364 #[test]
365 fn test_next_id_considers_done_even_with_queue_errors() {
366 let temp = TempDir::new().unwrap();
367 let queue_tasks = vec![
370 task("RQ-0001", TaskStatus::Todo),
371 task("RQ-0001", TaskStatus::Todo), ];
373 let done_tasks = vec![task("RQ-0100", TaskStatus::Done)];
374 let resolved = setup_test_queues(&temp, queue_tasks, done_tasks);
375
376 let args = QueueNextIdArgs { count: 1 };
377 let result = handle(&resolved, args);
378
379 assert!(result.is_ok());
381 }
382
383 #[test]
384 fn test_all_queue_tasks_are_duplicates() {
385 let temp = TempDir::new().unwrap();
386 let queue_tasks = vec![
388 task("RQ-0001", TaskStatus::Todo),
389 task("RQ-0001", TaskStatus::Todo),
390 task("RQ-0001", TaskStatus::Todo),
391 ];
392 let resolved = setup_test_queues(&temp, queue_tasks, vec![]);
393
394 let args = QueueNextIdArgs { count: 1 };
395 let result = handle(&resolved, args);
396
397 assert!(result.is_ok());
399 }
400
401 #[test]
402 fn test_rejected_tasks_still_checked_for_duplicates() {
403 let temp = TempDir::new().unwrap();
404 let queue_tasks = vec![
406 task("RQ-0001", TaskStatus::Todo),
407 task("RQ-0001", TaskStatus::Rejected), ];
409 let resolved = setup_test_queues(&temp, queue_tasks, vec![]);
410
411 let args = QueueNextIdArgs { count: 1 };
412 let result = handle(&resolved, args);
413
414 assert!(result.is_ok());
416 }
417}