Skip to main content

ralph/queue/
id.rs

1//! Task ID generation and formatting utilities.
2//!
3//! Responsibilities:
4//! - Generate next available task ID across active and done queues.
5//! - Normalize ID prefixes and format IDs with proper zero-padding.
6//!
7//! Not handled here:
8//! - ID validation (see `queue::validation`).
9//! - Queue persistence or file operations.
10//!
11//! Invariants/assumptions:
12//! - Task IDs follow the pattern: PREFIX-XXXX where X is a digit.
13//! - Rejected tasks are skipped when determining next ID.
14
15use 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}