Skip to main content

mana_core/
handle.rs

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
96/// Generate a short project-scoped human handle from a unit title.
97///
98/// Handles are navigation aliases, not canonical identity. The generator keeps
99/// the first three meaningful title words after removing common stop words and
100/// noisy planning vocabulary, falling back to title words when a short/generic
101/// title has fewer than three meaningful words.
102pub 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
133/// Normalize a handle or user-provided handle query for exact matching.
134pub 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(&current) {
147                words.push(std::mem::take(&mut current));
148            } else {
149                current.clear();
150            }
151        }
152    }
153
154    if !current.is_empty() && keep_word(&current) {
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}