1use crate::contracts::{QueueFile, TaskStatus};
16use crate::queue::validation::{self, log_warnings, validate_queue_set};
17use anyhow::Result;
18
19pub fn next_id_across(
20 active: &QueueFile,
21 done: Option<&QueueFile>,
22 id_prefix: &str,
23 id_width: usize,
24 max_dependency_depth: u8,
25) -> Result<String> {
26 let warnings = validate_queue_set(active, done, id_prefix, id_width, max_dependency_depth)?;
27 log_warnings(&warnings);
28 let expected_prefix = normalize_prefix(id_prefix);
29
30 let mut max_value: u32 = 0;
31 for (idx, task) in active.tasks.iter().enumerate() {
32 let value = validation::validate_task_id(idx, &task.id, &expected_prefix, id_width)?;
33 if task.status == TaskStatus::Rejected {
34 continue;
35 }
36 if value > max_value {
37 max_value = value;
38 }
39 }
40 if let Some(done) = done {
41 for (idx, task) in done.tasks.iter().enumerate() {
42 let value = validation::validate_task_id(idx, &task.id, &expected_prefix, id_width)?;
43 if task.status == TaskStatus::Rejected {
44 continue;
45 }
46 if value > max_value {
47 max_value = value;
48 }
49 }
50 }
51
52 let next_value = max_value.saturating_add(1);
53 Ok(format_id(&expected_prefix, next_value, id_width))
54}
55
56pub fn normalize_prefix(prefix: &str) -> String {
57 prefix.trim().to_uppercase()
58}
59
60pub fn format_id(prefix: &str, number: u32, width: usize) -> String {
61 format!("{}-{:0width$}", prefix, number, width = width)
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67 use crate::contracts::{QueueFile, Task, TaskStatus};
68 use std::collections::HashMap;
69
70 fn task(id: &str) -> Task {
71 Task {
72 id: id.to_string(),
73 status: TaskStatus::Todo,
74 title: "Test task".to_string(),
75 description: None,
76 priority: Default::default(),
77 tags: vec!["code".to_string()],
78 scope: vec!["crates/ralph".to_string()],
79 evidence: vec!["observed".to_string()],
80 plan: vec!["do thing".to_string()],
81 notes: vec![],
82 request: Some("test request".to_string()),
83 agent: None,
84 created_at: Some("2026-01-18T00:00:00Z".to_string()),
85 updated_at: Some("2026-01-18T00:00:00Z".to_string()),
86 completed_at: None,
87 started_at: None,
88 scheduled_start: None,
89 depends_on: vec![],
90 blocks: vec![],
91 relates_to: vec![],
92 duplicates: None,
93 custom_fields: HashMap::new(),
94 parent_id: None,
95 estimated_minutes: None,
96 actual_minutes: None,
97 }
98 }
99
100 fn task_with(id: &str, status: TaskStatus, tags: Vec<String>) -> Task {
101 Task {
102 id: id.to_string(),
103 status,
104 title: "Test task".to_string(),
105 description: None,
106 priority: Default::default(),
107 tags,
108 scope: vec!["crates/ralph".to_string()],
109 evidence: vec!["observed".to_string()],
110 plan: vec!["do thing".to_string()],
111 notes: vec![],
112 request: Some("test request".to_string()),
113 agent: None,
114 created_at: Some("2026-01-18T00:00:00Z".to_string()),
115 updated_at: Some("2026-01-18T00:00:00Z".to_string()),
116 completed_at: None,
117 started_at: None,
118 scheduled_start: None,
119 depends_on: vec![],
120 blocks: vec![],
121 relates_to: vec![],
122 duplicates: None,
123 custom_fields: HashMap::new(),
124 parent_id: None,
125 estimated_minutes: None,
126 actual_minutes: None,
127 }
128 }
129
130 #[test]
131 fn next_id_across_includes_done() -> Result<()> {
132 let active = QueueFile {
133 version: 1,
134 tasks: vec![task("RQ-0002")],
135 };
136 let mut done_task = task_with("RQ-0009", TaskStatus::Done, vec!["tag".to_string()]);
137 done_task.completed_at = Some("2026-01-18T00:00:00Z".to_string());
138 let done = QueueFile {
139 version: 1,
140 tasks: vec![done_task],
141 };
142 let next = next_id_across(&active, Some(&done), "RQ", 4, 10)?;
143 assert_eq!(next, "RQ-0010");
144 Ok(())
145 }
146
147 #[test]
148 fn next_id_across_ignores_rejected() -> Result<()> {
149 let mut t_rejected = task_with("RQ-0009", TaskStatus::Rejected, vec!["tag".to_string()]);
150 t_rejected.completed_at = Some("2026-01-18T00:00:00Z".to_string());
151 let active = QueueFile {
152 version: 1,
153 tasks: vec![
154 task_with("RQ-0001", TaskStatus::Todo, vec!["tag".to_string()]),
155 t_rejected,
156 ],
157 };
158 let next = next_id_across(&active, None, "RQ", 4, 10)?;
159 assert_eq!(next, "RQ-0002");
160 Ok(())
161 }
162
163 #[test]
164 fn next_id_across_includes_done_non_rejected() -> Result<()> {
165 let active = QueueFile {
166 version: 1,
167 tasks: vec![task_with(
168 "RQ-0001",
169 TaskStatus::Todo,
170 vec!["tag".to_string()],
171 )],
172 };
173 let mut t_done = task_with("RQ-0005", TaskStatus::Done, vec!["tag".to_string()]);
174 t_done.completed_at = Some("2026-01-18T00:00:00Z".to_string());
175 let mut t_rejected = task_with("RQ-0009", TaskStatus::Rejected, vec!["tag".to_string()]);
176 t_rejected.completed_at = Some("2026-01-18T00:00:00Z".to_string());
177 let done = QueueFile {
178 version: 1,
179 tasks: vec![t_done, t_rejected],
180 };
181 let next = next_id_across(&active, Some(&done), "RQ", 4, 10)?;
182 assert_eq!(next, "RQ-0006");
183 Ok(())
184 }
185}