tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Short, collision-resistant issue identifiers.
//!
//! An issue id is an **8-character Crockford base32** string (40 random bits),
//! generated with no coordination: two `tix create` runs, in two clones, on two
//! branches, produce different ids with overwhelming probability, so a new issue
//! is globally identifiable without a shared allocator. It is the canonical
//! machine identity; the integer [`crate::model::Issue::number`] stays the
//! human-facing, per-checkout alias and is allowed to collide across branches.
//!
//! The alphabet is Crockford base32 (`0-9`, then `A-Z` minus `I`, `L`, `O`,
//! `U`), the same one relationship ids use. Two properties matter:
//!
//! - **Case-insensitive** — an id is stored uppercase and matched with
//!   [`eq_ignore_ascii_case`], so `1a2b3c4d` selects the same issue as
//!   `1A2B3C4D`.
//! - **Never numeric** — the first character is drawn from the 22 letter
//!   symbols, so an id can never be all digits and can never be mistaken for an
//!   issue number by the selector grammar (a purely numeric selector stays the
//!   fast `number` path; see [`crate::commands::parse_selector`]).

use std::hash::{BuildHasher, Hasher};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

/// Crockford base32 alphabet: no `I`, `L`, `O`, or `U`.
pub(crate) const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";

/// Index of the first letter symbol in [`CROCKFORD`] (i.e. the end of `0..=9`).
const FIRST_LETTER: u64 = 10;

/// An issue id is exactly this many Crockford base32 characters.
pub const ISSUE_ID_LEN: usize = 8;

/// Encode the low `width * 5` bits of `value` as `width` Crockford base32
/// characters, most significant first.
pub(crate) fn crockford(value: u128, width: usize) -> String {
    let mut out = vec![b'0'; width];
    let mut remaining = value;
    for slot in out.iter_mut().rev() {
        *slot = CROCKFORD[(remaining & 0x1F) as usize];
        remaining >>= 5;
    }
    String::from_utf8(out).expect("Crockford alphabet is ASCII")
}

/// Generate a fresh issue id: [`ISSUE_ID_LEN`] Crockford base32 characters.
///
/// Randomness comes from the OS-seeded hasher state plus a process-local
/// counter and the wall clock, so the crate keeps **zero new dependencies**
/// (the same approach [`crate::relationships::generate_relationship_id`] uses).
/// The first character is a letter by construction, so the result is never a
/// bare integer.
pub fn generate_issue_id() -> String {
    use std::sync::LazyLock;

    static COUNTER: AtomicU64 = AtomicU64::new(0);
    // One OS-seeded random value per process, so ids from the same run are
    // independent of every other run.
    static SEED: LazyLock<u64> = LazyLock::new(|| {
        let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
        hasher.write_u64(0);
        hasher.finish()
    });

    let millis = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0);
    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
    // Mix seed, wall clock and counter with a full-avalanche finalizer so every
    // bit of the result depends on every input bit. Sampling the low 40 bits of
    // the *raw* hash lost most of its entropy (the OR-masked variant produced
    // hundreds of duplicates per 10 000 ids); this does not.
    let mut bits =
        splitmix64(*SEED ^ millis.rotate_left(32) ^ counter.wrapping_mul(0x9E37_79B9_7F4A_7C15));

    let mut out = String::with_capacity(ISSUE_ID_LEN);
    // Leading letter: `FIRST_LETTER..32`.
    out.push(CROCKFORD[(FIRST_LETTER + (bits % (32 - FIRST_LETTER))) as usize] as char);
    bits /= 32 - FIRST_LETTER;
    for _ in 1..ISSUE_ID_LEN {
        out.push(CROCKFORD[(bits & 0x1F) as usize] as char);
        bits >>= 5;
    }
    out
}

/// A full-avalanche 64-bit mixer (splitmix64's finalizer).
fn splitmix64(mut z: u64) -> u64 {
    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
    z ^ (z >> 31)
}

/// Whether `s` is syntactically an issue id: [`ISSUE_ID_LEN`] Crockford base32
/// characters whose first symbol is a letter.
///
/// Case-insensitive, matching the storage convention (ids are written
/// uppercase). This is a shape check, not a lookup — an issue whose id this
/// accepts may still not exist.
#[must_use]
pub fn is_issue_id(s: &str) -> bool {
    let bytes = s.as_bytes();
    if bytes.len() != ISSUE_ID_LEN || !bytes[0].is_ascii_alphabetic() {
        return false;
    }
    bytes
        .iter()
        .all(|b| CROCKFORD.contains(&b.to_ascii_uppercase()))
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use std::collections::HashSet;

    #[test]
    fn crockford_encodes_fixed_width_and_excludes_ambiguous_letters() {
        assert_eq!(crockford(0, 8), "00000000");
        assert_eq!(crockford(31, 8), "0000000Z");
        assert_eq!(crockford(32u128, 2), "10");
        // Crockford omits I, L, O and U.
        for b in b"ILOU" {
            assert!(!CROCKFORD.contains(b), "{b} must not be in the alphabet");
        }
    }

    #[test]
    fn generated_ids_are_eight_crockford_chars_and_never_numeric() {
        for _ in 0..1_000 {
            let id = generate_issue_id();
            assert_eq!(id.len(), ISSUE_ID_LEN, "{id}");
            assert!(
                id.parse::<u32>().is_err(),
                "id {id} must not parse as an issue number"
            );
            assert!(is_issue_id(&id), "{id} must be a valid id");
        }
    }

    #[test]
    fn generated_ids_are_distinct_within_a_process() {
        let ids: HashSet<String> = (0..10_000).map(|_| generate_issue_id()).collect();
        assert_eq!(ids.len(), 10_000);
    }

    #[test]
    fn is_issue_id_is_case_insensitive_and_rejects_bad_shapes() {
        let id = generate_issue_id();
        assert!(is_issue_id(&id));
        assert!(is_issue_id(&id.to_ascii_lowercase()));
        assert!(!is_issue_id("12345678"), "all digits is not an id");
        assert!(!is_issue_id("1234567"), "too short");
        assert!(!is_issue_id("123456789"), "too long");
        assert!(!is_issue_id("ABCDEFGI"), "I is not in the alphabet");
        assert!(!is_issue_id(""), "empty");
    }
}