1use 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
16pub 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
26fn 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 if name.starts_with(date) && name.ends_with(".md") {
38 let parts: Vec<&str> = name.trim_end_matches(".md").split('-').collect();
40 if parts.len() >= 5 {
41 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
53pub 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
78pub 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
94fn 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#[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 pub fn parse(input: &str) -> Result<Self> {
134 if input.is_empty() {
135 return Err(anyhow!("Spec ID cannot be empty"));
136 }
137
138 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 let (base_id, member) = Self::parse_base_id(remainder)?;
154
155 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 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 if let Some(dot_pos) = input.rfind('.') {
174 let (base, suffix) = input.split_at(dot_pos);
175 if suffix.len() > 1 {
177 let num_str = &suffix[1..];
178 if let Some(first_char) = num_str.chars().next() {
180 if first_char.is_ascii_digit() {
181 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 fn extract_project(base_id: &str) -> Result<(Option<String>, String)> {
199 let parts: Vec<&str> = base_id.split('-').collect();
200
201 if parts.len() < 5 {
204 return Ok((None, base_id.to_string()));
205 }
206
207 if parts[0].len() == 4 && parts[0].chars().all(|c| c.is_ascii_digit()) {
209 return Ok((None, base_id.to_string()));
211 }
212
213 for i in 1..parts.len() {
215 if parts[i].len() == 4 && parts[i].chars().all(|c| c.is_ascii_digit()) {
216 let project = parts[0..i].join("-");
218 let rest = parts[i..].join("-");
219 return Ok((Some(project), rest));
220 }
221 }
222
223 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
244fn 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 #[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}