1use std::collections::BTreeMap;
22use std::fmt;
23use std::hash::{BuildHasher, Hasher};
24use std::str::FromStr;
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use serde::{Deserialize, Deserializer, Serialize, Serializer};
29
30pub(crate) const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
32
33const FIRST_LETTER: u64 = 10;
35
36pub const ISSUE_ID_LEN: usize = 8;
38
39pub(crate) fn crockford(value: u128, width: usize) -> String {
42 let mut out = vec![b'0'; width];
43 let mut remaining = value;
44 for slot in out.iter_mut().rev() {
45 *slot = CROCKFORD[(remaining & 0x1F) as usize];
46 remaining >>= 5;
47 }
48 String::from_utf8(out).expect("Crockford alphabet is ASCII")
49}
50
51pub fn generate_issue_id() -> String {
59 use std::sync::LazyLock;
60
61 static COUNTER: AtomicU64 = AtomicU64::new(0);
62 static SEED: LazyLock<u64> = LazyLock::new(|| {
65 let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
66 hasher.write_u64(0);
67 hasher.finish()
68 });
69
70 let millis = SystemTime::now()
71 .duration_since(UNIX_EPOCH)
72 .map(|d| d.as_millis() as u64)
73 .unwrap_or(0);
74 let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
75 let mut bits =
80 splitmix64(*SEED ^ millis.rotate_left(32) ^ counter.wrapping_mul(0x9E37_79B9_7F4A_7C15));
81
82 let mut out = String::with_capacity(ISSUE_ID_LEN);
83 out.push(CROCKFORD[(FIRST_LETTER + (bits % (32 - FIRST_LETTER))) as usize] as char);
85 bits /= 32 - FIRST_LETTER;
86 for _ in 1..ISSUE_ID_LEN {
87 out.push(CROCKFORD[(bits & 0x1F) as usize] as char);
88 bits >>= 5;
89 }
90 out
91}
92
93fn splitmix64(mut z: u64) -> u64 {
95 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
96 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
97 z ^ (z >> 31)
98}
99
100#[must_use]
107pub fn is_issue_id(s: &str) -> bool {
108 let bytes = s.as_bytes();
109 if bytes.len() != ISSUE_ID_LEN || !bytes[0].is_ascii_alphabetic() {
110 return false;
111 }
112 bytes
113 .iter()
114 .all(|b| CROCKFORD.contains(&b.to_ascii_uppercase()))
115}
116
117#[derive(Debug, Clone, Copy)]
123pub enum IssueRef {
124 Id {
126 id: [u8; ISSUE_ID_LEN],
128 number: Option<u32>,
130 },
131 Number(u32),
133}
134
135impl IssueRef {
136 pub fn id(value: impl Into<String>) -> Option<Self> {
138 let value = value.into();
139 let value = value.trim();
140 if !is_issue_id(value) {
141 return None;
142 }
143 let mut bytes = [0; ISSUE_ID_LEN];
144 for (slot, byte) in bytes.iter_mut().zip(value.bytes()) {
145 *slot = byte.to_ascii_uppercase();
146 }
147 Some(Self::Id {
148 id: bytes,
149 number: None,
150 })
151 }
152
153 pub const fn number(value: u32) -> Self {
155 Self::Number(value)
156 }
157
158 #[must_use]
160 pub fn with_number(self, value: u32) -> Self {
161 match self {
162 Self::Id { id, .. } => Self::Id {
163 id,
164 number: Some(value),
165 },
166 Self::Number(_) => self,
167 }
168 }
169
170 #[must_use]
172 pub fn as_id(&self) -> Option<&str> {
173 match self {
174 Self::Id { id, .. } => std::str::from_utf8(id).ok(),
175 Self::Number(_) => None,
176 }
177 }
178
179 #[must_use]
181 pub const fn as_number(&self) -> Option<u32> {
182 match self {
183 Self::Id { number, .. } => *number,
184 Self::Number(number) => Some(*number),
185 }
186 }
187
188 #[must_use]
190 pub fn key(&self) -> String {
191 match self {
192 Self::Id { id, .. } => String::from_utf8_lossy(id).into_owned(),
193 Self::Number(number) => format!("#{number}"),
194 }
195 }
196
197 #[must_use]
203 pub fn order_key(&self) -> (u32, String) {
204 (self.as_number().unwrap_or(u32::MAX), self.key())
205 }
206
207 #[must_use]
209 pub fn human(&self) -> String {
210 self.as_number()
211 .map_or_else(|| self.to_string(), |number| format!("#{number}"))
212 }
213}
214
215impl fmt::Display for IssueRef {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217 match self {
218 Self::Id { id, .. } => f.write_str(&String::from_utf8_lossy(id)),
219 Self::Number(number) => write!(f, "#{number}"),
220 }
221 }
222}
223
224impl PartialEq for IssueRef {
225 fn eq(&self, other: &Self) -> bool {
226 match (self, other) {
227 (Self::Id { id: left, .. }, Self::Id { id: right, .. }) => left == right,
228 (Self::Number(left), Self::Number(right)) => left == right,
229 _ => false,
230 }
231 }
232}
233
234impl Eq for IssueRef {}
235
236impl std::hash::Hash for IssueRef {
237 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
238 match self {
239 Self::Id { id, .. } => {
240 0u8.hash(state);
241 id.hash(state);
242 }
243 Self::Number(number) => {
244 1u8.hash(state);
245 number.hash(state);
246 }
247 }
248 }
249}
250
251impl PartialOrd for IssueRef {
252 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
253 Some(self.cmp(other))
254 }
255}
256
257impl Ord for IssueRef {
258 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
259 match (self, other) {
260 (Self::Id { id: left, .. }, Self::Id { id: right, .. }) => left.cmp(right),
261 (Self::Number(left), Self::Number(right)) => left.cmp(right),
262 (Self::Id { .. }, Self::Number(_)) => std::cmp::Ordering::Less,
263 (Self::Number(_), Self::Id { .. }) => std::cmp::Ordering::Greater,
264 }
265 }
266}
267
268impl From<u32> for IssueRef {
269 fn from(value: u32) -> Self {
270 Self::Number(value)
271 }
272}
273
274impl PartialEq<u32> for IssueRef {
275 fn eq(&self, other: &u32) -> bool {
276 self.as_number() == Some(*other)
277 }
278}
279
280impl PartialEq<IssueRef> for u32 {
281 fn eq(&self, other: &IssueRef) -> bool {
282 other.as_number() == Some(*self)
283 }
284}
285
286impl From<&str> for IssueRef {
287 fn from(value: &str) -> Self {
288 Self::id(value).unwrap_or_else(|| {
289 value.parse::<u32>().map_or_else(
290 |_| Self::Id {
291 id: id_bytes_lossy(value),
292 number: None,
293 },
294 Self::Number,
295 )
296 })
297 }
298}
299
300fn id_bytes_lossy(value: &str) -> [u8; ISSUE_ID_LEN] {
301 let mut bytes = [b'0'; ISSUE_ID_LEN];
302 for (slot, byte) in bytes.iter_mut().zip(value.bytes().take(ISSUE_ID_LEN)) {
303 *slot = byte.to_ascii_uppercase();
304 }
305 bytes
306}
307
308impl From<String> for IssueRef {
309 fn from(value: String) -> Self {
310 Self::from(value.as_str())
311 }
312}
313
314impl FromStr for IssueRef {
315 type Err = std::convert::Infallible;
316
317 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
318 Ok(Self::from(value))
319 }
320}
321
322impl Serialize for IssueRef {
323 fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
324 match self {
325 Self::Id { id, .. } => serializer.serialize_str(&String::from_utf8_lossy(id)),
326 Self::Number(number) => serializer.serialize_u32(*number),
327 }
328 }
329}
330
331impl<'de> Deserialize<'de> for IssueRef {
332 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
333 struct Visitor;
334
335 impl serde::de::Visitor<'_> for Visitor {
336 type Value = IssueRef;
337
338 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
339 formatter.write_str("an 8-character issue id or a numeric issue alias")
340 }
341
342 fn visit_u64<E: serde::de::Error>(
343 self,
344 value: u64,
345 ) -> std::result::Result<Self::Value, E> {
346 u32::try_from(value)
347 .map(IssueRef::Number)
348 .map_err(|_| E::custom(format!("issue alias {value} exceeds u32")))
349 }
350
351 fn visit_str<E: serde::de::Error>(
352 self,
353 value: &str,
354 ) -> std::result::Result<Self::Value, E> {
355 let value = value.trim();
356 if let Some(reference) = IssueRef::id(value) {
357 return Ok(reference);
358 }
359 Err(E::custom(format!(
360 "`{value}` is not an 8-character issue id"
361 )))
362 }
363 }
364
365 deserializer.deserialize_any(Visitor)
366 }
367}
368
369pub trait IdentityNode {
371 fn canonical_id(&self) -> Option<&str>;
373 fn issue_number(&self) -> u32;
375 fn issue_filename(&self) -> &str;
377 fn issue_title(&self) -> &str;
379}
380
381#[must_use]
383pub fn canonical_ref<N: IdentityNode + ?Sized>(node: &N) -> IssueRef {
384 node.canonical_id()
385 .and_then(IssueRef::id)
386 .map(|reference| reference.with_number(node.issue_number()))
387 .unwrap_or_else(|| IssueRef::Number(node.issue_number()))
388}
389
390#[derive(Debug, Clone, PartialEq, Eq)]
392pub enum AliasError {
393 Missing(String),
395 Ambiguous {
397 alias: String,
399 candidates: Vec<IssueRef>,
401 },
402 DuplicateId {
404 id: String,
406 numbers: Vec<u32>,
408 },
409}
410
411#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct DuplicateIdentityRecord {
414 pub id: String,
416 pub number: u32,
418 pub filename: String,
420}
421
422#[derive(Debug, Clone, Default, PartialEq, Eq)]
428pub struct AliasIndex {
429 scanned_nodes: usize,
431 by_id: BTreeMap<String, IssueRef>,
432 by_number: BTreeMap<u32, Vec<IssueRef>>,
433 by_filename: BTreeMap<String, Vec<IssueRef>>,
434 by_title: BTreeMap<String, Vec<IssueRef>>,
435 duplicate_ids: BTreeMap<String, Vec<u32>>,
436 duplicate_id_files: BTreeMap<String, Vec<String>>,
437 duplicate_records: Vec<DuplicateIdentityRecord>,
438 canonical: BTreeMap<IssueRef, IssueRef>,
439}
440
441impl AliasIndex {
442 #[must_use]
444 pub fn len(&self) -> usize {
445 self.canonical.len()
446 }
447
448 #[must_use]
450 pub fn is_empty(&self) -> bool {
451 self.canonical.is_empty()
452 }
453
454 #[must_use]
456 pub fn scanned_nodes(&self) -> usize {
457 self.scanned_nodes
458 }
459
460 pub fn build<N: IdentityNode>(nodes: &[N]) -> Self {
462 let mut index = Self::default();
463 let mut id_records: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
464 for node in nodes {
465 index.scanned_nodes += 1;
466 let canonical = canonical_ref(node);
467 index.canonical.insert(canonical, canonical);
468 index
469 .by_number
470 .entry(node.issue_number())
471 .or_default()
472 .push(canonical);
473 index
474 .by_filename
475 .entry(node.issue_filename().to_string())
476 .or_default()
477 .push(canonical);
478 index
479 .by_title
480 .entry(node.issue_title().to_string())
481 .or_default()
482 .push(canonical);
483 if let Some(id) = node.canonical_id().and_then(IssueRef::id) {
484 let key = id.key();
485 index.by_id.insert(key.clone(), canonical);
486 id_records
487 .entry(key)
488 .or_default()
489 .push((node.issue_number(), node.issue_filename().to_string()));
490 }
491 }
492 for (id, records) in id_records
493 .into_iter()
494 .filter(|(_, records)| records.len() > 1)
495 {
496 let mut numbers: Vec<u32> = records.iter().map(|(number, _)| *number).collect();
497 numbers.sort_unstable();
498 numbers.dedup();
499 let mut files: Vec<String> = records
500 .iter()
501 .map(|(_, filename)| filename.clone())
502 .collect();
503 files.sort();
504 files.dedup();
505 index.duplicate_ids.insert(id.clone(), numbers);
506 index.duplicate_id_files.insert(id.clone(), files);
507 index
508 .duplicate_records
509 .extend(
510 records
511 .into_iter()
512 .map(|(number, filename)| DuplicateIdentityRecord {
513 id: id.clone(),
514 number,
515 filename,
516 }),
517 );
518 }
519 index.duplicate_records.sort_by(|left, right| {
520 (&left.id, left.number, &left.filename).cmp(&(&right.id, right.number, &right.filename))
521 });
522 for values in index
523 .by_number
524 .values_mut()
525 .chain(index.by_filename.values_mut())
526 .chain(index.by_title.values_mut())
527 {
528 values.sort();
529 values.dedup();
530 }
531 index
532 }
533
534 #[must_use]
536 pub fn duplicate_ids(&self) -> &BTreeMap<String, Vec<u32>> {
537 &self.duplicate_ids
538 }
539
540 #[must_use]
542 pub fn duplicate_id_files(&self) -> &BTreeMap<String, Vec<String>> {
543 &self.duplicate_id_files
544 }
545
546 #[must_use]
548 pub fn duplicate_records(&self) -> &[DuplicateIdentityRecord] {
549 &self.duplicate_records
550 }
551
552 pub fn resolve(&self, reference: &IssueRef) -> std::result::Result<IssueRef, AliasError> {
554 match reference {
555 IssueRef::Id { .. } => {
556 let key = reference.key();
557 if let Some(numbers) = self.duplicate_ids.get(&key) {
558 return Err(AliasError::DuplicateId {
559 id: key,
560 numbers: numbers.clone(),
561 });
562 }
563 self.by_id
564 .get(&key)
565 .copied()
566 .ok_or(AliasError::Missing(key))
567 }
568 IssueRef::Number(number) => self.resolve_number(*number),
569 }
570 }
571
572 pub fn resolve_number(&self, number: u32) -> std::result::Result<IssueRef, AliasError> {
574 Self::resolve_many(
575 &self.by_number.get(&number).cloned().unwrap_or_default(),
576 number.to_string(),
577 )
578 }
579
580 pub fn resolve_filename(&self, filename: &str) -> std::result::Result<IssueRef, AliasError> {
582 Self::resolve_many(
583 &self.by_filename.get(filename).cloned().unwrap_or_default(),
584 filename.to_string(),
585 )
586 }
587
588 pub fn resolve_title(&self, title: &str) -> std::result::Result<IssueRef, AliasError> {
590 Self::resolve_many(
591 &self.by_title.get(title).cloned().unwrap_or_default(),
592 title.to_string(),
593 )
594 }
595
596 fn resolve_many(
597 candidates: &[IssueRef],
598 alias: String,
599 ) -> std::result::Result<IssueRef, AliasError> {
600 match candidates {
601 [] => Err(AliasError::Missing(alias)),
602 [one] => Ok(*one),
603 many => Err(AliasError::Ambiguous {
604 alias,
605 candidates: many.to_vec(),
606 }),
607 }
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 #[allow(clippy::wildcard_imports)]
614 use super::*;
615 use std::collections::HashSet;
616
617 #[test]
618 fn crockford_encodes_fixed_width_and_excludes_ambiguous_letters() {
619 assert_eq!(crockford(0, 8), "00000000");
620 assert_eq!(crockford(31, 8), "0000000Z");
621 assert_eq!(crockford(32u128, 2), "10");
622 for b in b"ILOU" {
624 assert!(!CROCKFORD.contains(b), "{b} must not be in the alphabet");
625 }
626 }
627
628 #[test]
629 fn generated_ids_are_eight_crockford_chars_and_never_numeric() {
630 for _ in 0..1_000 {
631 let id = generate_issue_id();
632 assert_eq!(id.len(), ISSUE_ID_LEN, "{id}");
633 assert!(
634 id.parse::<u32>().is_err(),
635 "id {id} must not parse as an issue number"
636 );
637 assert!(is_issue_id(&id), "{id} must be a valid id");
638 }
639 }
640
641 #[test]
642 fn generated_ids_are_distinct_within_a_process() {
643 let ids: HashSet<String> = (0..10_000).map(|_| generate_issue_id()).collect();
644 assert_eq!(ids.len(), 10_000);
645 }
646
647 #[test]
648 fn is_issue_id_is_case_insensitive_and_rejects_bad_shapes() {
649 let id = generate_issue_id();
650 assert!(is_issue_id(&id));
651 assert!(is_issue_id(&id.to_ascii_lowercase()));
652 assert!(!is_issue_id("12345678"), "all digits is not an id");
653 assert!(!is_issue_id("1234567"), "too short");
654 assert!(!is_issue_id("123456789"), "too long");
655 assert!(!is_issue_id("ABCDEFGI"), "I is not in the alphabet");
656 assert!(!is_issue_id(""), "empty");
657 }
658
659 #[test]
660 fn canonical_graph_references_serialize_ids_and_read_numeric_migration_aliases() {
661 let id = IssueRef::id("a1b2c3d4").unwrap();
662 assert_eq!(serde_yaml::to_string(&id).unwrap().trim(), "A1B2C3D4");
663 let from_id: IssueRef = serde_yaml::from_str("A1B2C3D4").unwrap();
664 assert_eq!(from_id, id);
665 let alias: IssueRef = serde_yaml::from_str("7").unwrap();
666 assert_eq!(alias, IssueRef::Number(7));
667 assert!(serde_yaml::from_str::<IssueRef>("'7'").is_err());
668 }
669
670 struct Node {
671 id: Option<&'static str>,
672 number: u32,
673 filename: &'static str,
674 title: &'static str,
675 }
676
677 impl IdentityNode for Node {
678 fn canonical_id(&self) -> Option<&str> {
679 self.id
680 }
681 fn issue_number(&self) -> u32 {
682 self.number
683 }
684 fn issue_filename(&self) -> &str {
685 self.filename
686 }
687 fn issue_title(&self) -> &str {
688 self.title
689 }
690 }
691
692 #[test]
693 fn alias_index_resolves_unique_aliases_and_refuses_missing_ambiguous_and_duplicate_ids() {
694 let nodes = [
695 Node {
696 id: Some("A1B2C3D4"),
697 number: 1,
698 filename: "one.md",
699 title: "One",
700 },
701 Node {
702 id: Some("B1B2C3D4"),
703 number: 2,
704 filename: "two.md",
705 title: "Two",
706 },
707 ];
708 let index = AliasIndex::build(&nodes);
709 assert_eq!(
710 index.resolve_number(1).unwrap(),
711 IssueRef::id("A1B2C3D4").unwrap()
712 );
713 assert!(matches!(
714 index.resolve_number(9),
715 Err(AliasError::Missing(_))
716 ));
717
718 let ambiguous = [
719 nodes[0].id.map(|_| "A1B2C3D4"),
720 nodes[1].id.map(|_| "B1B2C3D4"),
721 ]
722 .into_iter()
723 .enumerate()
724 .map(|(i, id)| Node {
725 id,
726 number: 7,
727 filename: if i == 0 { "a.md" } else { "b.md" },
728 title: if i == 0 { "A" } else { "B" },
729 })
730 .collect::<Vec<_>>();
731 let index = AliasIndex::build(&ambiguous);
732 assert!(matches!(
733 index.resolve_number(7),
734 Err(AliasError::Ambiguous { .. })
735 ));
736
737 let duplicates = [
738 Node {
739 id: Some("A1B2C3D4"),
740 number: 1,
741 filename: "a.md",
742 title: "A",
743 },
744 Node {
745 id: Some("A1B2C3D4"),
746 number: 2,
747 filename: "b.md",
748 title: "B",
749 },
750 ];
751 let index = AliasIndex::build(&duplicates);
752 assert_eq!(index.duplicate_ids().get("A1B2C3D4"), Some(&vec![1, 2]));
753 assert_eq!(
754 index.duplicate_id_files().get("A1B2C3D4"),
755 Some(&vec!["a.md".to_string(), "b.md".to_string()])
756 );
757 assert_eq!(
758 index
759 .duplicate_records()
760 .iter()
761 .map(|record| (record.number, record.filename.as_str()))
762 .collect::<Vec<_>>(),
763 vec![(1, "a.md"), (2, "b.md")]
764 );
765 assert!(matches!(
766 index.resolve(&IssueRef::id("A1B2C3D4").unwrap()),
767 Err(AliasError::DuplicateId { .. })
768 ));
769 }
770}