Skip to main content

chant/
id.rs

1//! Spec ID generation with date-based sequencing.
2//!
3//! # Doc Audit
4//! - audited: 2026-01-25
5//! - docs: concepts/ids.md, reference/schema.md
6//! - ignore: false
7
8use anyhow::{anyhow, Result};
9use chrono::Local;
10use rand::Rng;
11use std::fmt::{self, Display, Formatter};
12use std::path::Path;
13
14const BASE36_CHARS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
15
16/// Generate a new spec ID in the format: YYYY-MM-DD-SSS-XXX
17/// where SSS is a base36 sequence and XXX is a random base36 suffix.
18pub fn generate_id(specs_dir: &Path) -> Result<String> {
19    let date = Local::now().format("%Y-%m-%d").to_string();
20    let seq = next_sequence_for_date(specs_dir, &date)?;
21    let rand = random_base36(3);
22
23    Ok(format!("{}-{}-{}", date, format_base36(seq, 3), rand))
24}
25
26/// Get the next sequence number for a given date.
27fn next_sequence_for_date(specs_dir: &Path, date: &str) -> Result<u32> {
28    let mut max_seq = 0u32;
29
30    if specs_dir.exists() {
31        for entry in std::fs::read_dir(specs_dir)? {
32            let entry = entry?;
33            let filename = entry.file_name();
34            let name = filename.to_string_lossy();
35
36            // Match pattern: YYYY-MM-DD-SSS-XXX.md or YYYY-MM-DD-SSS-XXX.N.md (group member)
37            if name.starts_with(date) && name.ends_with(".md") {
38                // Extract the sequence part (after the date, before the random suffix)
39                let parts: Vec<&str> = name.trim_end_matches(".md").split('-').collect();
40                if parts.len() >= 5 {
41                    // parts: [YYYY, MM, DD, SSS, XXX] or [YYYY, MM, DD, SSS, XXX.N]
42                    if let Some(seq) = parse_base36(parts[3]) {
43                        max_seq = max_seq.max(seq);
44                    }
45                }
46            }
47        }
48    }
49
50    Ok(max_seq + 1)
51}
52
53/// Format a number as base36 with zero-padding.
54pub fn format_base36(n: u32, width: usize) -> String {
55    if n == 0 {
56        return "0".repeat(width);
57    }
58
59    let mut result = Vec::new();
60    let mut num = n;
61
62    while num > 0 {
63        let digit = (num % 36) as usize;
64        result.push(BASE36_CHARS[digit] as char);
65        num /= 36;
66    }
67
68    result.reverse();
69    let s: String = result.into_iter().collect();
70
71    if s.len() < width {
72        format!("{:0>width$}", s, width = width)
73    } else {
74        s
75    }
76}
77
78/// Parse a base36 string to a number.
79pub fn parse_base36(s: &str) -> Option<u32> {
80    let mut result = 0u32;
81
82    for c in s.chars() {
83        result *= 36;
84        if let Some(pos) = BASE36_CHARS.iter().position(|&b| b as char == c) {
85            result += pos as u32;
86        } else {
87            return None;
88        }
89    }
90
91    Some(result)
92}
93
94/// Generate a random base36 string of the given length.
95fn random_base36(len: usize) -> String {
96    let mut rng = rand::thread_rng();
97    (0..len)
98        .map(|_| BASE36_CHARS[rng.gen_range(0..36)] as char)
99        .collect()
100}
101
102/// Represents a parsed spec ID with optional repo prefix.
103///
104/// Spec IDs can have three formats:
105/// - `2026-01-27-001-abc` (local spec, no repo prefix)
106/// - `project-2026-01-27-001-abc` (local spec with project prefix)
107/// - `backend:2026-01-27-001-abc` (cross-repo spec without project)
108/// - `backend:auth-2026-01-27-001-abc` (cross-repo spec with project)
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct SpecId {
111    pub repo: Option<String>,
112    pub project: Option<String>,
113    pub base_id: String,
114    pub member: Option<u32>,
115}
116
117impl SpecId {
118    /// Parse a spec ID string into a SpecId struct.
119    ///
120    /// Supports formats:
121    /// - `2026-01-27-001-abc` - local spec
122    /// - `project-2026-01-27-001-abc` - local spec with project
123    /// - `backend:2026-01-27-001-abc` - cross-repo spec
124    /// - `backend:project-2026-01-27-001-abc` - cross-repo with project
125    /// - Any of above with `.N` suffix for group members
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if:
130    /// - Repo name contains invalid characters (not alphanumeric, hyphen, or underscore)
131    /// - Repo name is empty
132    /// - Base ID format is invalid
133    pub fn parse(input: &str) -> Result<Self> {
134        if input.is_empty() {
135            return Err(anyhow!("Spec ID cannot be empty"));
136        }
137
138        // Check for repo prefix (first `:`)
139        let (repo, remainder) = if let Some(colon_pos) = input.find(':') {
140            let repo_name = &input[..colon_pos];
141            if repo_name.is_empty() {
142                return Err(anyhow!("Repo name cannot be empty before ':'"));
143            }
144            if !is_valid_repo_name(repo_name) {
145                return Err(anyhow!("Invalid repo name '{}': must contain only alphanumeric characters, hyphens, and underscores", repo_name));
146            }
147            (Some(repo_name.to_string()), &input[colon_pos + 1..])
148        } else {
149            (None, input)
150        };
151
152        // Parse the remainder as: [project-]base_id[.member]
153        let (base_id, member) = Self::parse_base_id(remainder)?;
154
155        // Check if base_id has a project prefix
156        let (project, base_id) = Self::extract_project(&base_id)?;
157
158        Ok(SpecId {
159            repo,
160            project,
161            base_id,
162            member,
163        })
164    }
165
166    /// Parse the base ID part, handling member suffixes.
167    fn parse_base_id(input: &str) -> Result<(String, Option<u32>)> {
168        if input.is_empty() {
169            return Err(anyhow!("Base ID cannot be empty"));
170        }
171
172        // Check for member suffix (.N)
173        if let Some(dot_pos) = input.rfind('.') {
174            let (base, suffix) = input.split_at(dot_pos);
175            // Check if suffix is numeric
176            if suffix.len() > 1 {
177                let num_str = &suffix[1..];
178                // Check if the first part after dot is numeric
179                if let Some(first_char) = num_str.chars().next() {
180                    if first_char.is_ascii_digit() {
181                        // Try to parse as member number
182                        let member_part: String =
183                            num_str.chars().take_while(|c| c.is_ascii_digit()).collect();
184                        if let Ok(member_num) = member_part.parse::<u32>() {
185                            return Ok((base.to_string(), Some(member_num)));
186                        }
187                    }
188                }
189            }
190        }
191
192        Ok((input.to_string(), None))
193    }
194
195    /// Extract project prefix from base_id if present.
196    /// Project prefix format: `project-rest` where project is alphanumeric with hyphens/underscores.
197    /// Looks for a 4-digit year (YYYY) in the string to detect the start of the date part.
198    fn extract_project(base_id: &str) -> Result<(Option<String>, String)> {
199        let parts: Vec<&str> = base_id.split('-').collect();
200
201        // Need at least 5 parts for any valid spec ID: [YYYY, MM, DD, SSS, XXX]
202        // If less than 5 parts, no project prefix possible
203        if parts.len() < 5 {
204            return Ok((None, base_id.to_string()));
205        }
206
207        // Check if parts[0] looks like a year (YYYY)
208        if parts[0].len() == 4 && parts[0].chars().all(|c| c.is_ascii_digit()) {
209            // No project prefix, starts with year directly
210            return Ok((None, base_id.to_string()));
211        }
212
213        // Look for a 4-digit year anywhere in the parts after position 0
214        for i in 1..parts.len() {
215            if parts[i].len() == 4 && parts[i].chars().all(|c| c.is_ascii_digit()) {
216                // Found a year at position i, everything before is the project
217                let project = parts[0..i].join("-");
218                let rest = parts[i..].join("-");
219                return Ok((Some(project), rest));
220            }
221        }
222
223        // No year found, treat as no project
224        Ok((None, base_id.to_string()))
225    }
226}
227
228impl Display for SpecId {
229    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
230        if let Some(repo) = &self.repo {
231            write!(f, "{}:", repo)?;
232        }
233        if let Some(project) = &self.project {
234            write!(f, "{}-", project)?;
235        }
236        write!(f, "{}", self.base_id)?;
237        if let Some(member) = self.member {
238            write!(f, ".{}", member)?;
239        }
240        Ok(())
241    }
242}
243
244/// Check if a string is a valid repo name.
245/// Valid names contain only alphanumeric characters, hyphens, and underscores.
246fn is_valid_repo_name(name: &str) -> bool {
247    !name.is_empty()
248        && name
249            .chars()
250            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_format_base36() {
259        assert_eq!(format_base36(0, 3), "000");
260        assert_eq!(format_base36(35, 3), "00z");
261        assert_eq!(format_base36(36, 3), "010");
262        assert_eq!(format_base36(1000, 3), "0rs");
263    }
264
265    #[test]
266    fn test_parse_base36() {
267        assert_eq!(parse_base36("000"), Some(0));
268        assert_eq!(parse_base36("00z"), Some(35));
269        assert_eq!(parse_base36("010"), Some(36));
270    }
271
272    #[test]
273    fn test_random_base36_length() {
274        let r = random_base36(3);
275        assert_eq!(r.len(), 3);
276        assert!(r.chars().all(|c| BASE36_CHARS.contains(&(c as u8))));
277    }
278
279    // SpecId tests
280
281    #[test]
282    fn test_parse_local_id_without_project() {
283        let spec = SpecId::parse("2026-01-27-001-abc").unwrap();
284        assert_eq!(spec.repo, None);
285        assert_eq!(spec.project, None);
286        assert_eq!(spec.base_id, "2026-01-27-001-abc");
287        assert_eq!(spec.member, None);
288    }
289
290    #[test]
291    fn test_parse_repo_id_with_project_and_member() {
292        let spec = SpecId::parse("backend:auth-2026-01-27-001-abc.5").unwrap();
293        assert_eq!(spec.repo, Some("backend".to_string()));
294        assert_eq!(spec.project, Some("auth".to_string()));
295        assert_eq!(spec.base_id, "2026-01-27-001-abc");
296        assert_eq!(spec.member, Some(5));
297    }
298
299    #[test]
300    fn test_invalid_repo_name_empty_before_colon() {
301        let result = SpecId::parse(":2026-01-27-001-abc");
302        assert!(result.is_err());
303        assert!(result.unwrap_err().to_string().contains("empty"));
304    }
305
306    #[test]
307    fn test_invalid_repo_name_with_special_chars() {
308        let result = SpecId::parse("back@end:2026-01-27-001-abc");
309        assert!(result.is_err());
310    }
311
312    #[test]
313    fn test_parse_and_display_roundtrip() {
314        let inputs = vec![
315            "2026-01-27-001-abc",
316            "auth-2026-01-27-001-abc",
317            "backend:2026-01-27-001-abc",
318            "backend:auth-2026-01-27-001-abc",
319            "2026-01-27-001-abc.1",
320            "auth-2026-01-27-001-abc.2",
321            "backend:2026-01-27-001-abc.3",
322            "backend:auth-2026-01-27-001-abc.4",
323            "my-repo:my-proj-2026-01-27-001-abc.5",
324        ];
325
326        for input in inputs {
327            let spec = SpecId::parse(input).unwrap();
328            assert_eq!(spec.to_string(), input);
329        }
330    }
331}