Skip to main content

ralph/cli/queue/
next_id.rs

1//! Queue next-id subcommand.
2//!
3//! Responsibilities:
4//! - Generate one or more sequential task IDs based on current queue state.
5//! - Validate count bounds (1..=MAX_COUNT) to prevent abuse.
6//! - Work correctly even when duplicate task IDs exist (graceful degradation).
7//!
8//! Not handled here:
9//! - Queue modification (this is a read-only operation).
10//! - ID reservation (IDs are generated but not claimed; callers must create tasks promptly).
11//! - Full queue validation (duplicates are warned but don't block ID generation).
12//!
13//! Invariants/assumptions:
14//! - Count must be between 1 and MAX_COUNT (100) inclusive.
15//! - Generated IDs are sequential and unique within the current queue state.
16//! - Output format: one ID per line for easy shell scripting.
17//! - Duplicate IDs in queue.json or done.json are warned but don't prevent operation.
18
19use 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    /// Number of IDs to generate
33    #[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    // Validate count bounds
39    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    // Load queues without validation to handle duplicate IDs gracefully
51    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    // Collect all IDs and detect duplicates
55    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    // Process active queue
61    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    // Process done queue
78    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    // Log duplicate warnings
95    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    // Parse the numeric portion from the first ID
107    let prefix_len = resolved.id_prefix.len() + 1; // +1 for the hyphen
108    let first_num: u32 = first_id[prefix_len..].parse()?;
109
110    // Generate and print all IDs
111    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        // Create empty done file
175        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        // 1 should be allowed (minimum valid count)
224        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        // 100 should be allowed
265        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        // Setup queue with duplicate IDs: RQ-0001, RQ-0002, RQ-0001
312        let queue_tasks = vec![
313            task("RQ-0001", TaskStatus::Todo),
314            task("RQ-0002", TaskStatus::Todo),
315            task("RQ-0001", TaskStatus::Todo), // duplicate
316        ];
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        // Should succeed and return RQ-0003 (max is 0002, plus 1)
323        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        // Setup: RQ-0001 in queue.json, RQ-0001 and RQ-0005 in done.json
330        let queue_tasks = vec![task("RQ-0001", TaskStatus::Todo)];
331        let done_tasks = vec![
332            task("RQ-0001", TaskStatus::Done), // duplicate across files
333            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        // Should succeed and return RQ-0006 (max is 0005, plus 1)
341        assert!(result.is_ok());
342    }
343
344    #[test]
345    fn test_multiple_duplicates_returns_correct_next_id() {
346        let temp = TempDir::new().unwrap();
347        // Setup: RQ-0001, RQ-0002, RQ-0001, RQ-0003, RQ-0002 (duplicates of 1 and 2)
348        let queue_tasks = vec![
349            task("RQ-0001", TaskStatus::Todo),
350            task("RQ-0002", TaskStatus::Todo),
351            task("RQ-0001", TaskStatus::Todo), // duplicate of 1
352            task("RQ-0003", TaskStatus::Todo),
353            task("RQ-0002", TaskStatus::Todo), // duplicate of 2
354        ];
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        // Should succeed and return RQ-0004 (max is 0003, plus 1)
361        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        // Setup: Invalid/duplicate IDs in queue.json
368        // Setup: Valid high-numbered task in done.json (RQ-0100)
369        let queue_tasks = vec![
370            task("RQ-0001", TaskStatus::Todo),
371            task("RQ-0001", TaskStatus::Todo), // duplicate
372        ];
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        // Should still return RQ-0101 (not RQ-0002) because done.json is considered
380        assert!(result.is_ok());
381    }
382
383    #[test]
384    fn test_all_queue_tasks_are_duplicates() {
385        let temp = TempDir::new().unwrap();
386        // Edge case: all tasks in queue are duplicates of the same ID
387        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        // Should succeed and return RQ-0002
398        assert!(result.is_ok());
399    }
400
401    #[test]
402    fn test_rejected_tasks_still_checked_for_duplicates() {
403        let temp = TempDir::new().unwrap();
404        // Rejected tasks should still count toward duplicate detection
405        let queue_tasks = vec![
406            task("RQ-0001", TaskStatus::Todo),
407            task("RQ-0001", TaskStatus::Rejected), // duplicate, though rejected
408        ];
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        // Should succeed (rejected status doesn't prevent duplicate detection)
415        assert!(result.is_ok());
416    }
417}