1use std::fmt;
2
3pub const DEFAULT_TICKET_PREFIX: &str = "TICK";
4pub const DEFAULT_PROJECT_PREFIX: &str = "PROJ";
5
6pub 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
18pub 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
33pub fn valid_slug(value: &str) -> bool {
37 !value.is_empty()
38 && value.split('-').all(|segment| {
39 !segment.is_empty()
40 && segment
41 .bytes()
42 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
43 })
44}
45
46pub fn default_worktree(stem: Option<&str>, ticket_id: &str) -> Result<String, String> {
51 match stem {
52 None => Ok(format!("sloop/{ticket_id}")),
53 Some(stem) if valid_slug(stem) => Ok(format!("sloop/{stem}")),
54 Some(stem) => Err(format!(
55 "file stem `{stem}` is not a valid worktree slug; \
56 rename the file to `abc-def` form or set `worktree:` explicitly"
57 )),
58 }
59}
60
61fn numeric_suffix(prefix: &str, id: &str) -> Option<u64> {
62 let suffix = id.strip_prefix(prefix)?.strip_prefix('-')?;
63 if suffix.is_empty() || !suffix.bytes().all(|byte| byte.is_ascii_digit()) {
64 return None;
65 }
66 suffix.parse::<u64>().ok().filter(|ordinal| *ordinal > 0)
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum IdError {
71 Exhausted,
72}
73
74impl fmt::Display for IdError {
75 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
76 match self {
77 Self::Exhausted => formatter.write_str("ID counter is exhausted"),
78 }
79 }
80}
81
82impl std::error::Error for IdError {}
83
84#[cfg(test)]
85mod tests {
86 use super::{default_worktree, next_id, valid_prefix, valid_slug};
87
88 #[test]
89 fn allocation_uses_the_greatest_matching_numeric_suffix() {
90 let ids = ["TICK-2", "TICK-9", "TICK-4", "OTHER-100", "TICK-nope"];
91 assert_eq!(next_id("TICK", ids).unwrap(), "TICK-10");
92 }
93
94 #[test]
95 fn allocation_starts_at_one_and_accepts_a_configured_prefix() {
96 assert_eq!(next_id("WORK", []).unwrap(), "WORK-1");
97 }
98
99 #[test]
100 fn slug_validation_accepts_only_kebab_case() {
101 for slug in ["abc", "abc-def", "a-2-c", "fix-login2"] {
102 assert!(valid_slug(slug), "{slug}");
103 }
104 for slug in ["", "-abc", "abc-", "a--b", "Fix-Login", "fix_login", "a b"] {
105 assert!(!valid_slug(slug), "{slug}");
106 }
107 }
108
109 #[test]
110 fn worktree_defaults_to_the_stem_and_refuses_invalid_stems() {
111 assert_eq!(
112 default_worktree(Some("admission-snapshots"), "TICK-19").unwrap(),
113 "sloop/admission-snapshots"
114 );
115 assert_eq!(default_worktree(None, "TICK-19").unwrap(), "sloop/TICK-19");
116 let refusal = default_worktree(Some("Fix_Login"), "TICK-19").unwrap_err();
117 assert!(refusal.contains("`Fix_Login`"), "{refusal}");
118 assert!(refusal.contains("abc-def"), "{refusal}");
119 }
120
121 #[test]
122 fn prefix_validation_rejects_empty_or_unsafe_values() {
123 for prefix in ["WORK", "my-work", "TEAM_2"] {
124 assert!(valid_prefix(prefix), "{prefix}");
125 }
126 for prefix in ["", "-WORK", "WORK-", "two words", "work/queue"] {
127 assert!(!valid_prefix(prefix), "{prefix}");
128 }
129 }
130}