1use std::collections::HashSet;
2
3const STOP_WORDS: &[&str] = &[
4 "a",
5 "an",
6 "and",
7 "are",
8 "as",
9 "at",
10 "be",
11 "by",
12 "for",
13 "from",
14 "has",
15 "have",
16 "in",
17 "into",
18 "is",
19 "it",
20 "its",
21 "of",
22 "on",
23 "or",
24 "over",
25 "than",
26 "that",
27 "the",
28 "this",
29 "to",
30 "under",
31 "with",
32 "without",
33 "via",
34 "after",
35 "before",
36 "beyond",
37 "across",
38 "while",
39 "when",
40 "where",
41 "why",
42 "how",
43 "then",
44 "now",
45 "current",
46 "existing",
47 "old",
48 "new",
49 "next",
50 "previous",
51 "future",
52 "first",
53 "second",
54 "third",
55 "v1",
56 "v2",
57 "v3",
58 "phase",
59 "slice",
60 "task",
61 "unit",
62 "epic",
63 "goal",
64 "feature",
65 "implement",
66 "implementation",
67 "add",
68 "adds",
69 "added",
70 "make",
71 "create",
72 "define",
73 "plan",
74 "fix",
75 "support",
76 "wire",
77 "use",
78 "using",
79 "based",
80 "native",
81 "canonical",
82 "durable",
83 "explicit",
84 "specific",
85 "clean",
86 "project",
87 "mana",
88 "imp",
89 "agent",
90 "agents",
91 "workflow",
92 "work",
93 "system",
94];
95
96pub fn generate_handle(title: &str) -> Option<String> {
103 let tokens = tokenize_title(title);
104 if tokens.is_empty() {
105 return None;
106 }
107
108 let stop_words: HashSet<&str> = STOP_WORDS.iter().copied().collect();
109 let meaningful: Vec<String> = tokens
110 .iter()
111 .filter(|word| !stop_words.contains(word.as_str()))
112 .filter(|word| !word.chars().all(|c| c.is_ascii_digit()))
113 .cloned()
114 .collect();
115
116 let mut selected = Vec::new();
117 for word in meaningful.iter().chain(tokens.iter()) {
118 if !selected.contains(word) && !word.chars().all(|c| c.is_ascii_digit()) {
119 selected.push(word.clone());
120 }
121 if selected.len() == 3 {
122 break;
123 }
124 }
125
126 if selected.is_empty() {
127 None
128 } else {
129 Some(selected.join(" "))
130 }
131}
132
133pub fn normalize_handle(value: &str) -> String {
135 tokenize_title(value).join(" ")
136}
137
138fn tokenize_title(title: &str) -> Vec<String> {
139 let mut words = Vec::new();
140 let mut current = String::new();
141
142 for ch in title.chars().flat_map(char::to_lowercase) {
143 if ch.is_ascii_alphanumeric() {
144 current.push(ch);
145 } else if !current.is_empty() {
146 if keep_word(¤t) {
147 words.push(std::mem::take(&mut current));
148 } else {
149 current.clear();
150 }
151 }
152 }
153
154 if !current.is_empty() && keep_word(¤t) {
155 words.push(current);
156 }
157
158 words
159}
160
161fn keep_word(word: &str) -> bool {
162 word.len() > 1 || word.chars().all(|c| c.is_ascii_digit())
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn handle_generation_keeps_three_meaningful_words() {
171 assert_eq!(
172 generate_handle("Implement SQLite-derived index for mana agent context assembly")
173 .as_deref(),
174 Some("sqlite derived index")
175 );
176 }
177
178 #[test]
179 fn handle_generation_falls_back_for_short_titles() {
180 assert_eq!(
181 generate_handle("Onboarding improvements").as_deref(),
182 Some("onboarding improvements")
183 );
184 }
185
186 #[test]
187 fn handle_generation_ignores_punctuation_and_numbers_when_possible() {
188 assert_eq!(
189 generate_handle("Vibecheck: Improve crates/uu/src/cmd/doctor.rs").as_deref(),
190 Some("vibecheck improve crates")
191 );
192 }
193
194 #[test]
195 fn normalize_handle_matches_generated_shape() {
196 assert_eq!(
197 normalize_handle("SQLite-Derived Index"),
198 "sqlite derived index"
199 );
200 }
201}