Skip to main content

sloop/
ids.rs

1use std::fmt;
2
3pub const DEFAULT_TICKET_PREFIX: &str = "TICK";
4pub const DEFAULT_PROJECT_PREFIX: &str = "PROJ";
5
6/// Prefixes stay safe as unquoted frontmatter scalars while still allowing
7/// readable multi-part names such as `MY-WORK`.
8pub fn valid_prefix(prefix: &str) -> bool {
9    let bytes = prefix.as_bytes();
10    !bytes.is_empty()
11        && bytes.first().is_some_and(u8::is_ascii_alphanumeric)
12        && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
13        && bytes
14            .iter()
15            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
16}
17
18/// Allocates one greater than the greatest positive numeric suffix for the
19/// active prefix. IDs for other prefixes and malformed suffixes do not count.
20pub fn next_id<'a>(
21    prefix: &str,
22    existing: impl IntoIterator<Item = &'a str>,
23) -> Result<String, IdError> {
24    let greatest = existing
25        .into_iter()
26        .filter_map(|id| numeric_suffix(prefix, id))
27        .max()
28        .unwrap_or(0);
29    let ordinal = greatest.checked_add(1).ok_or(IdError::Exhausted)?;
30    Ok(format!("{prefix}-{ordinal}"))
31}
32
33fn numeric_suffix(prefix: &str, id: &str) -> Option<u64> {
34    let suffix = id.strip_prefix(prefix)?.strip_prefix('-')?;
35    if suffix.is_empty() || !suffix.bytes().all(|byte| byte.is_ascii_digit()) {
36        return None;
37    }
38    suffix.parse::<u64>().ok().filter(|ordinal| *ordinal > 0)
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum IdError {
43    Exhausted,
44}
45
46impl fmt::Display for IdError {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            Self::Exhausted => formatter.write_str("ID counter is exhausted"),
50        }
51    }
52}
53
54impl std::error::Error for IdError {}
55
56#[cfg(test)]
57mod tests {
58    use super::{next_id, valid_prefix};
59
60    #[test]
61    fn allocation_uses_the_greatest_matching_numeric_suffix() {
62        let ids = ["TICK-2", "TICK-9", "TICK-4", "OTHER-100", "TICK-nope"];
63        assert_eq!(next_id("TICK", ids).unwrap(), "TICK-10");
64    }
65
66    #[test]
67    fn allocation_starts_at_one_and_accepts_a_configured_prefix() {
68        assert_eq!(next_id("WORK", []).unwrap(), "WORK-1");
69    }
70
71    #[test]
72    fn prefix_validation_rejects_empty_or_unsafe_values() {
73        for prefix in ["WORK", "my-work", "TEAM_2"] {
74            assert!(valid_prefix(prefix), "{prefix}");
75        }
76        for prefix in ["", "-WORK", "WORK-", "two words", "work/queue"] {
77            assert!(!valid_prefix(prefix), "{prefix}");
78        }
79    }
80}