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(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 #[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}