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
33/// The numeric ordinal an id ends in, whatever its prefix: `TICK-38` is 38.
34/// Allocation makes this a monotonic proxy for registration order, so it is
35/// what recency ordering falls back to when timestamps tie. Ids that do not
36/// end in `-<digits>` have none.
37pub fn ordinal(id: &str) -> Option<u64> {
38    let (_, suffix) = id.rsplit_once('-')?;
39    if suffix.is_empty() || !suffix.bytes().all(|byte| byte.is_ascii_digit()) {
40        return None;
41    }
42    suffix.parse().ok()
43}
44
45/// Worktree slugs are what a ticket file stem must look like to name a
46/// branch: lowercase alphanumeric segments separated by single hyphens,
47/// `abc-def`.
48pub fn valid_slug(value: &str) -> bool {
49    !value.is_empty()
50        && value.split('-').all(|segment| {
51            !segment.is_empty()
52                && segment
53                    .bytes()
54                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
55        })
56}
57
58/// Chooses the worktree branch for a ticket whose frontmatter does not name
59/// one. `stem` is the ticket file's stem; exec-sourced tickets have none and
60/// always fall back to `sloop/<ticket_id>`. `Err` refuses the ticket with the
61/// given reason: reindex holds it, `sloop post` rejects it.
62pub fn default_worktree(stem: Option<&str>, ticket_id: &str) -> Result<String, String> {
63    match stem {
64        None => Ok(format!("sloop/{ticket_id}")),
65        Some(stem) if valid_slug(stem) => Ok(format!("sloop/{stem}")),
66        Some(stem) => Err(format!(
67            "file stem `{stem}` is not a valid worktree slug; \
68             rename the file to `abc-def` form or set `worktree:` explicitly"
69        )),
70    }
71}
72
73fn numeric_suffix(prefix: &str, id: &str) -> Option<u64> {
74    let suffix = id.strip_prefix(prefix)?.strip_prefix('-')?;
75    if suffix.is_empty() || !suffix.bytes().all(|byte| byte.is_ascii_digit()) {
76        return None;
77    }
78    suffix.parse::<u64>().ok().filter(|ordinal| *ordinal > 0)
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum IdError {
83    Exhausted,
84}
85
86impl fmt::Display for IdError {
87    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match self {
89            Self::Exhausted => formatter.write_str("ID counter is exhausted"),
90        }
91    }
92}
93
94impl std::error::Error for IdError {}
95
96#[cfg(test)]
97mod tests {
98    use super::{default_worktree, next_id, ordinal, valid_prefix, valid_slug};
99
100    #[test]
101    fn ordinals_compare_numerically_across_prefixes() {
102        assert_eq!(ordinal("TICK-38"), Some(38));
103        assert_eq!(ordinal("MY-WORK-9"), Some(9));
104        assert!(ordinal("TICK-38") > ordinal("TICK-9"));
105        for id in ["TICK", "TICK-", "TICK-abc", "TICK-1a"] {
106            assert_eq!(ordinal(id), None, "{id}");
107        }
108    }
109
110    #[test]
111    fn allocation_uses_the_greatest_matching_numeric_suffix() {
112        let ids = ["TICK-2", "TICK-9", "TICK-4", "OTHER-100", "TICK-nope"];
113        assert_eq!(next_id("TICK", ids).unwrap(), "TICK-10");
114    }
115
116    #[test]
117    fn allocation_starts_at_one_and_accepts_a_configured_prefix() {
118        assert_eq!(next_id("WORK", []).unwrap(), "WORK-1");
119    }
120
121    #[test]
122    fn slug_validation_accepts_only_kebab_case() {
123        for slug in ["abc", "abc-def", "a-2-c", "fix-login2"] {
124            assert!(valid_slug(slug), "{slug}");
125        }
126        for slug in ["", "-abc", "abc-", "a--b", "Fix-Login", "fix_login", "a b"] {
127            assert!(!valid_slug(slug), "{slug}");
128        }
129    }
130
131    #[test]
132    fn worktree_defaults_to_the_stem_and_refuses_invalid_stems() {
133        assert_eq!(
134            default_worktree(Some("admission-snapshots"), "TICK-19").unwrap(),
135            "sloop/admission-snapshots"
136        );
137        assert_eq!(default_worktree(None, "TICK-19").unwrap(), "sloop/TICK-19");
138        let refusal = default_worktree(Some("Fix_Login"), "TICK-19").unwrap_err();
139        assert!(refusal.contains("`Fix_Login`"), "{refusal}");
140        assert!(refusal.contains("abc-def"), "{refusal}");
141    }
142
143    #[test]
144    fn prefix_validation_rejects_empty_or_unsafe_values() {
145        for prefix in ["WORK", "my-work", "TEAM_2"] {
146            assert!(valid_prefix(prefix), "{prefix}");
147        }
148        for prefix in ["", "-WORK", "WORK-", "two words", "work/queue"] {
149            assert!(!valid_prefix(prefix), "{prefix}");
150        }
151    }
152}