Skip to main content

tix/
id.rs

1//! Short, collision-resistant issue identifiers.
2//!
3//! An issue id is an **8-character Crockford base32** string (40 random bits),
4//! generated with no coordination: two `tix create` runs, in two clones, on two
5//! branches, produce different ids with overwhelming probability, so a new issue
6//! is globally identifiable without a shared allocator. It is the canonical
7//! machine identity; the integer [`crate::model::Issue::number`] stays the
8//! human-facing, per-checkout alias and is allowed to collide across branches.
9//!
10//! The alphabet is Crockford base32 (`0-9`, then `A-Z` minus `I`, `L`, `O`,
11//! `U`), the same one relationship ids use. Two properties matter:
12//!
13//! - **Case-insensitive** — an id is stored uppercase and matched with
14//!   [`eq_ignore_ascii_case`], so `1a2b3c4d` selects the same issue as
15//!   `1A2B3C4D`.
16//! - **Never numeric** — the first character is drawn from the 22 letter
17//!   symbols, so an id can never be all digits and can never be mistaken for an
18//!   issue number by the selector grammar (a purely numeric selector stays the
19//!   fast `number` path; see [`crate::commands::parse_selector`]).
20
21use 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
30/// Crockford base32 alphabet: no `I`, `L`, `O`, or `U`.
31pub(crate) const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
32
33/// Index of the first letter symbol in [`CROCKFORD`] (i.e. the end of `0..=9`).
34const FIRST_LETTER: u64 = 10;
35
36/// An issue id is exactly this many Crockford base32 characters.
37pub const ISSUE_ID_LEN: usize = 8;
38
39/// Encode the low `width * 5` bits of `value` as `width` Crockford base32
40/// characters, most significant first.
41pub(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
51/// Generate a fresh issue id: [`ISSUE_ID_LEN`] Crockford base32 characters.
52///
53/// Randomness comes from the OS-seeded hasher state plus a process-local
54/// counter and the wall clock, so the crate keeps **zero new dependencies**
55/// (the same approach [`crate::relationships::generate_relationship_id`] uses).
56/// The first character is a letter by construction, so the result is never a
57/// bare integer.
58pub fn generate_issue_id() -> String {
59    use std::sync::LazyLock;
60
61    static COUNTER: AtomicU64 = AtomicU64::new(0);
62    // One OS-seeded random value per process, so ids from the same run are
63    // independent of every other run.
64    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    // Mix seed, wall clock and counter with a full-avalanche finalizer so every
76    // bit of the result depends on every input bit. Sampling the low 40 bits of
77    // the *raw* hash lost most of its entropy (the OR-masked variant produced
78    // hundreds of duplicates per 10 000 ids); this does not.
79    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    // Leading letter: `FIRST_LETTER..32`.
84    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
93/// A full-avalanche 64-bit mixer (splitmix64's finalizer).
94fn 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/// Whether `s` is syntactically an issue id: [`ISSUE_ID_LEN`] Crockford base32
101/// characters whose first symbol is a letter.
102///
103/// Case-insensitive, matching the storage convention (ids are written
104/// uppercase). This is a shape check, not a lookup — an issue whose id this
105/// accepts may still not exist.
106#[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/// A reference to one issue in the local graph.
118///
119/// Canonical ids are the only identity written by current code. A numeric value
120/// exists solely as an input and migration alias for data created before issue
121/// ids became authoritative.
122#[derive(Debug, Clone, Copy)]
123pub enum IssueRef {
124    /// The collision-resistant canonical issue id, stored uppercase.
125    Id {
126        /// The fixed-width Crockford identity.
127        id: [u8; ISSUE_ID_LEN],
128        /// The resolved human alias, when the identity was built from an issue.
129        number: Option<u32>,
130    },
131    /// A human-facing issue number alias.
132    Number(u32),
133}
134
135impl IssueRef {
136    /// Build a canonical id reference, validating the id's shape.
137    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    /// A numeric alias.
154    pub const fn number(value: u32) -> Self {
155        Self::Number(value)
156    }
157
158    /// Attach the resolved human alias to a canonical reference.
159    #[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    /// The canonical id, when this reference already names one.
171    #[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    /// The numeric alias, when this reference stores one.
180    #[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    /// A stable key used for ordering and graph maps.
189    #[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    /// Stable ordering key that preserves the established human-facing order.
198    ///
199    /// Canonical ids remain the edge identity. The numeric alias is used only to
200    /// choose an arbitrary orientation for non-directional edges, a decision that
201    /// predates canonical ids and must not change with random id values.
202    #[must_use]
203    pub fn order_key(&self) -> (u32, String) {
204        (self.as_number().unwrap_or(u32::MAX), self.key())
205    }
206
207    /// Display the human alias when this reference carries one, else its id.
208    #[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
369/// The fields identity resolution needs from an issue-like value.
370pub trait IdentityNode {
371    /// The canonical issue id, when this record has one.
372    fn canonical_id(&self) -> Option<&str>;
373    /// The human-facing number alias.
374    fn issue_number(&self) -> u32;
375    /// The store-relative filename.
376    fn issue_filename(&self) -> &str;
377    /// The current title.
378    fn issue_title(&self) -> &str;
379}
380
381/// The canonical reference for one identity node.
382#[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/// Why a selector or stored alias did not resolve to one canonical reference.
391#[derive(Debug, Clone, PartialEq, Eq)]
392pub enum AliasError {
393    /// No issue carries the alias.
394    Missing(String),
395    /// More than one issue carries the alias.
396    Ambiguous {
397        /// The alias as supplied.
398        alias: String,
399        /// Every canonical reference the alias matches.
400        candidates: Vec<IssueRef>,
401    },
402    /// Two records claim one canonical id.
403    DuplicateId {
404        /// The duplicated canonical id.
405        id: String,
406        /// Every issue number claiming it.
407        numbers: Vec<u32>,
408    },
409}
410
411/// One file participating in a duplicated canonical identity.
412#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct DuplicateIdentityRecord {
414    /// The duplicated canonical id.
415    pub id: String,
416    /// The human-facing number claimed by the file.
417    pub number: u32,
418    /// The store-relative filename claiming the id.
419    pub filename: String,
420}
421
422/// One project-wide index from every accepted alias to a canonical issue reference.
423///
424/// The index is built once per read operation. Numbers, filenames, and titles are
425/// aliases; ids are identity. Duplicate ids are recorded and make the index
426/// unusable for graph work rather than allowing one file to shadow another.
427#[derive(Debug, Clone, Default, PartialEq, Eq)]
428pub struct AliasIndex {
429    /// How many input records the one index-building pass examined.
430    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    /// How many canonical issues the index holds.
443    #[must_use]
444    pub fn len(&self) -> usize {
445        self.canonical.len()
446    }
447
448    /// Whether the index contains no issue records.
449    #[must_use]
450    pub fn is_empty(&self) -> bool {
451        self.canonical.is_empty()
452    }
453
454    /// How many issue records the single index-building pass examined.
455    #[must_use]
456    pub fn scanned_nodes(&self) -> usize {
457        self.scanned_nodes
458    }
459
460    /// Build the complete alias index over one issue snapshot.
461    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    /// Every duplicated canonical id, in stable order.
535    #[must_use]
536    pub fn duplicate_ids(&self) -> &BTreeMap<String, Vec<u32>> {
537        &self.duplicate_ids
538    }
539
540    /// Every filename claiming each duplicated canonical id, in stable order.
541    #[must_use]
542    pub fn duplicate_id_files(&self) -> &BTreeMap<String, Vec<String>> {
543        &self.duplicate_id_files
544    }
545
546    /// One record per file claiming a duplicated canonical id.
547    #[must_use]
548    pub fn duplicate_records(&self) -> &[DuplicateIdentityRecord] {
549        &self.duplicate_records
550    }
551
552    /// Resolve a stored graph reference or selector alias to one canonical reference.
553    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    /// Resolve a numeric alias, refusing ambiguity.
573    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    /// Resolve an exact filename alias, refusing ambiguity.
581    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    /// Resolve an exact title alias, refusing ambiguity.
589    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        // Crockford omits I, L, O and U.
623        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}