1use std::fmt;
2
3pub const DEFAULT_TICKET_PREFIX: &str = "TICK";
4pub const DEFAULT_PROJECT_PREFIX: &str = "PROJ";
5
6pub const TRIGGER_ID_PREFIX: &str = "TR";
10
11pub const NOTE_ID_PREFIX: &str = "N";
13
14pub 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
26pub 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
41pub 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
53pub 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
66pub 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}