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(1, 3), "001");
261        assert_eq!(format_base36(10, 3), "00a");
262        assert_eq!(format_base36(35, 3), "00z");
263        assert_eq!(format_base36(36, 3), "010");
264        assert_eq!(format_base36(999, 3), "0rr");
265        assert_eq!(format_base36(1000, 3), "0rs");
266    }
267
268    #[test]
269    fn test_parse_base36() {
270        assert_eq!(parse_base36("000"), Some(0));
271        assert_eq!(parse_base36("001"), Some(1));
272        assert_eq!(parse_base36("00a"), Some(10));
273        assert_eq!(parse_base36("00z"), Some(35));
274        assert_eq!(parse_base36("010"), Some(36));
275    }
276
277    #[test]
278    fn test_random_base36_length() {
279        let r = random_base36(3);
280        assert_eq!(r.len(), 3);
281        assert!(r.chars().all(|c| BASE36_CHARS.contains(&(c as u8))));
282    }
283
284    // SpecId tests
285
286    #[test]
287    fn test_parse_local_id_without_project() {
288        let spec = SpecId::parse("2026-01-27-001-abc").unwrap();
289        assert_eq!(spec.repo, None);
290        assert_eq!(spec.project, None);
291        assert_eq!(spec.base_id, "2026-01-27-001-abc");
292        assert_eq!(spec.member, None);
293    }
294
295    #[test]
296    fn test_parse_local_id_with_project() {
297        let spec = SpecId::parse("auth-2026-01-27-001-abc").unwrap();
298        assert_eq!(spec.repo, None);
299        assert_eq!(spec.project, Some("auth".to_string()));
300        assert_eq!(spec.base_id, "2026-01-27-001-abc");
301        assert_eq!(spec.member, None);
302    }
303
304    #[test]
305    fn test_parse_repo_id_without_project() {
306        let spec = SpecId::parse("backend:2026-01-27-001-abc").unwrap();
307        assert_eq!(spec.repo, Some("backend".to_string()));
308        assert_eq!(spec.project, None);
309        assert_eq!(spec.base_id, "2026-01-27-001-abc");
310        assert_eq!(spec.member, None);
311    }
312
313    #[test]
314    fn test_parse_repo_id_with_project() {
315        let spec = SpecId::parse("backend:auth-2026-01-27-001-abc").unwrap();
316        assert_eq!(spec.repo, Some("backend".to_string()));
317        assert_eq!(spec.project, Some("auth".to_string()));
318        assert_eq!(spec.base_id, "2026-01-27-001-abc");
319        assert_eq!(spec.member, None);
320    }
321
322    #[test]
323    fn test_parse_local_id_with_member() {
324        let spec = SpecId::parse("2026-01-27-001-abc.1").unwrap();
325        assert_eq!(spec.repo, None);
326        assert_eq!(spec.project, None);
327        assert_eq!(spec.base_id, "2026-01-27-001-abc");
328        assert_eq!(spec.member, Some(1));
329    }
330
331    #[test]
332    fn test_parse_local_id_with_project_and_member() {
333        let spec = SpecId::parse("auth-2026-01-27-001-abc.3").unwrap();
334        assert_eq!(spec.repo, None);
335        assert_eq!(spec.project, Some("auth".to_string()));
336        assert_eq!(spec.base_id, "2026-01-27-001-abc");
337        assert_eq!(spec.member, Some(3));
338    }
339
340    #[test]
341    fn test_parse_repo_id_with_member() {
342        let spec = SpecId::parse("backend:2026-01-27-001-abc.2").unwrap();
343        assert_eq!(spec.repo, Some("backend".to_string()));
344        assert_eq!(spec.project, None);
345        assert_eq!(spec.base_id, "2026-01-27-001-abc");
346        assert_eq!(spec.member, Some(2));
347    }
348
349    #[test]
350    fn test_parse_repo_id_with_project_and_member() {
351        let spec = SpecId::parse("backend:auth-2026-01-27-001-abc.5").unwrap();
352        assert_eq!(spec.repo, Some("backend".to_string()));
353        assert_eq!(spec.project, Some("auth".to_string()));
354        assert_eq!(spec.base_id, "2026-01-27-001-abc");
355        assert_eq!(spec.member, Some(5));
356    }
357
358    #[test]
359    fn test_parse_repo_with_hyphen() {
360        let spec = SpecId::parse("my-repo:2026-01-27-001-abc").unwrap();
361        assert_eq!(spec.repo, Some("my-repo".to_string()));
362        assert_eq!(spec.project, None);
363        assert_eq!(spec.base_id, "2026-01-27-001-abc");
364    }
365
366    #[test]
367    fn test_parse_repo_with_underscore() {
368        let spec = SpecId::parse("my_repo:2026-01-27-001-abc").unwrap();
369        assert_eq!(spec.repo, Some("my_repo".to_string()));
370    }
371
372    #[test]
373    fn test_parse_project_with_hyphen() {
374        let spec = SpecId::parse("auth-service-2026-01-27-001-abc").unwrap();
375        assert_eq!(spec.project, Some("auth-service".to_string()));
376    }
377
378    #[test]
379    fn test_invalid_repo_name_empty_before_colon() {
380        let result = SpecId::parse(":2026-01-27-001-abc");
381        assert!(result.is_err());
382        assert!(result.unwrap_err().to_string().contains("empty"));
383    }
384
385    #[test]
386    fn test_invalid_repo_name_with_special_chars() {
387        let result = SpecId::parse("back@end:2026-01-27-001-abc");
388        assert!(result.is_err());
389    }
390
391    #[test]
392    fn test_invalid_repo_name_with_dot() {
393        let result = SpecId::parse("backend.com:2026-01-27-001-abc");
394        assert!(result.is_err());
395    }
396
397    #[test]
398    fn test_empty_spec_id() {
399        let result = SpecId::parse("");
400        assert!(result.is_err());
401    }
402
403    #[test]
404    fn test_display_local_id() {
405        let spec = SpecId {
406            repo: None,
407            project: None,
408            base_id: "2026-01-27-001-abc".to_string(),
409            member: None,
410        };
411        assert_eq!(spec.to_string(), "2026-01-27-001-abc");
412    }
413
414    #[test]
415    fn test_display_local_id_with_project() {
416        let spec = SpecId {
417            repo: None,
418            project: Some("auth".to_string()),
419            base_id: "2026-01-27-001-abc".to_string(),
420            member: None,
421        };
422        assert_eq!(spec.to_string(), "auth-2026-01-27-001-abc");
423    }
424
425    #[test]
426    fn test_display_repo_id() {
427        let spec = SpecId {
428            repo: Some("backend".to_string()),
429            project: None,
430            base_id: "2026-01-27-001-abc".to_string(),
431            member: None,
432        };
433        assert_eq!(spec.to_string(), "backend:2026-01-27-001-abc");
434    }
435
436    #[test]
437    fn test_display_repo_id_with_project() {
438        let spec = SpecId {
439            repo: Some("backend".to_string()),
440            project: Some("auth".to_string()),
441            base_id: "2026-01-27-001-abc".to_string(),
442            member: None,
443        };
444        assert_eq!(spec.to_string(), "backend:auth-2026-01-27-001-abc");
445    }
446
447    #[test]
448    fn test_display_with_member() {
449        let spec = SpecId {
450            repo: Some("backend".to_string()),
451            project: Some("auth".to_string()),
452            base_id: "2026-01-27-001-abc".to_string(),
453            member: Some(3),
454        };
455        assert_eq!(spec.to_string(), "backend:auth-2026-01-27-001-abc.3");
456    }
457
458    #[test]
459    fn test_parse_and_display_roundtrip() {
460        let inputs = vec![
461            "2026-01-27-001-abc",
462            "auth-2026-01-27-001-abc",
463            "backend:2026-01-27-001-abc",
464            "backend:auth-2026-01-27-001-abc",
465            "2026-01-27-001-abc.1",
466            "auth-2026-01-27-001-abc.2",
467            "backend:2026-01-27-001-abc.3",
468            "backend:auth-2026-01-27-001-abc.4",
469            "my-repo:my-proj-2026-01-27-001-abc.5",
470        ];
471
472        for input in inputs {
473            let spec = SpecId::parse(input).unwrap();
474            assert_eq!(spec.to_string(), input);
475        }
476    }
477
478    #[test]
479    fn test_parse_member_with_large_number() {
480        let spec = SpecId::parse("2026-01-27-001-abc.999").unwrap();
481        assert_eq!(spec.member, Some(999));
482    }
483}