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/// Trigger ids are `TR<ordinal>`, with no separator: `TR91`. Two letters, not
7/// one — a bare `T91` reads as a ticket id beside `TICK-91`, which is exactly
8/// the confusion an id prefix exists to prevent.
9pub const TRIGGER_ID_PREFIX: &str = "TR";
10
11/// Note ids are `N<ordinal>`.
12pub const NOTE_ID_PREFIX: &str = "N";
13
14/// Prefixes stay safe as unquoted frontmatter scalars while still allowing
15/// readable multi-part names such as `MY-WORK`.
16pub fn valid_prefix(prefix: &str) -> bool {
17    let bytes = prefix.as_bytes();
18    !bytes.is_empty()
19        && bytes.first().is_some_and(u8::is_ascii_alphanumeric)
20        && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
21        && bytes
22            .iter()
23            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
24}
25
26/// Allocates one greater than the greatest positive numeric suffix for the
27/// active prefix. IDs for other prefixes and malformed suffixes do not count.
28pub fn next_id<'a>(
29    prefix: &str,
30    existing: impl IntoIterator<Item = &'a str>,
31) -> Result<String, IdError> {
32    let greatest = existing
33        .into_iter()
34        .filter_map(|id| numeric_suffix(prefix, id))
35        .max()
36        .unwrap_or(0);
37    let ordinal = greatest.checked_add(1).ok_or(IdError::Exhausted)?;
38    Ok(format!("{prefix}-{ordinal}"))
39}
40
41/// The numeric ordinal an id ends in, whatever its prefix: `TICK-38` is 38.
42/// Allocation makes this a monotonic proxy for registration order, so it is
43/// what recency ordering falls back to when timestamps tie. Ids that do not
44/// end in `-<digits>` have none.
45pub fn ordinal(id: &str) -> Option<u64> {
46    let (_, suffix) = id.rsplit_once('-')?;
47    if suffix.is_empty() || !suffix.bytes().all(|byte| byte.is_ascii_digit()) {
48        return None;
49    }
50    suffix.parse().ok()
51}
52
53/// Worktree slugs are what a ticket file stem must look like to name a
54/// branch: lowercase alphanumeric segments separated by single hyphens,
55/// `abc-def`.
56pub fn valid_slug(value: &str) -> bool {
57    !value.is_empty()
58        && value.split('-').all(|segment| {
59            !segment.is_empty()
60                && segment
61                    .bytes()
62                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
63        })
64}
65
66/// Chooses the worktree branch for a ticket whose frontmatter does not name
67/// one. `stem` is the ticket file's stem; exec-sourced tickets have none and
68/// always fall back to `sloop/<ticket_id>`. `Err` refuses the ticket with the
69/// given reason: reindex holds it, `sloop post` rejects it.
70pub fn default_worktree(stem: Option<&str>, ticket_id: &str) -> Result<String, String> {
71    match stem {
72        None => Ok(format!("sloop/{ticket_id}")),
73        Some(stem) if valid_slug(stem) => Ok(format!("sloop/{stem}")),
74        Some(stem) => Err(format!(
75            "file stem `{stem}` is not a valid worktree slug; \
76             rename the file to `abc-def` form or set `worktree:` explicitly"
77        )),
78    }
79}
80
81fn numeric_suffix(prefix: &str, id: &str) -> Option<u64> {
82    let suffix = id.strip_prefix(prefix)?.strip_prefix('-')?;
83    if suffix.is_empty() || !suffix.bytes().all(|byte| byte.is_ascii_digit()) {
84        return None;
85    }
86    suffix.parse::<u64>().ok().filter(|ordinal| *ordinal > 0)
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum IdError {
91    Exhausted,
92}
93
94impl fmt::Display for IdError {
95    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            Self::Exhausted => formatter.write_str("ID counter is exhausted"),
98        }
99    }
100}
101
102impl std::error::Error for IdError {}
103
104#[cfg(test)]
105mod tests {
106    use super::{default_worktree, next_id, ordinal, valid_prefix, valid_slug};
107
108    #[test]
109    fn ordinals_compare_numerically_across_prefixes() {
110        assert_eq!(ordinal("TICK-38"), Some(38));
111        assert_eq!(ordinal("MY-WORK-9"), Some(9));
112        assert!(ordinal("TICK-38") > ordinal("TICK-9"));
113        for id in ["TICK", "TICK-", "TICK-abc", "TICK-1a"] {
114            assert_eq!(ordinal(id), None, "{id}");
115        }
116    }
117
118    #[test]
119    fn allocation_uses_the_greatest_matching_numeric_suffix() {
120        let ids = ["TICK-2", "TICK-9", "TICK-4", "OTHER-100", "TICK-nope"];
121        assert_eq!(next_id("TICK", ids).unwrap(), "TICK-10");
122    }
123
124    #[test]
125    fn allocation_starts_at_one_and_accepts_a_configured_prefix() {
126        assert_eq!(next_id("WORK", []).unwrap(), "WORK-1");
127    }
128
129    #[test]
130    fn slug_validation_accepts_only_kebab_case() {
131        for slug in ["abc", "abc-def", "a-2-c", "fix-login2"] {
132            assert!(valid_slug(slug), "{slug}");
133        }
134        for slug in ["", "-abc", "abc-", "a--b", "Fix-Login", "fix_login", "a b"] {
135            assert!(!valid_slug(slug), "{slug}");
136        }
137    }
138
139    #[test]
140    fn worktree_defaults_to_the_stem_and_refuses_invalid_stems() {
141        assert_eq!(
142            default_worktree(Some("admission-snapshots"), "TICK-19").unwrap(),
143            "sloop/admission-snapshots"
144        );
145        assert_eq!(default_worktree(None, "TICK-19").unwrap(), "sloop/TICK-19");
146        let refusal = default_worktree(Some("Fix_Login"), "TICK-19").unwrap_err();
147        assert!(refusal.contains("`Fix_Login`"), "{refusal}");
148        assert!(refusal.contains("abc-def"), "{refusal}");
149    }
150
151    #[test]
152    fn prefix_validation_rejects_empty_or_unsafe_values() {
153        for prefix in ["WORK", "my-work", "TEAM_2"] {
154            assert!(valid_prefix(prefix), "{prefix}");
155        }
156        for prefix in ["", "-WORK", "WORK-", "two words", "work/queue"] {
157            assert!(!valid_prefix(prefix), "{prefix}");
158        }
159    }
160}