opendev_runtime/
action_summarizer.rs1use std::borrow::Cow;
8
9const DEFAULT_MAX_LENGTH: usize = 60;
11
12const ACTION_VERBS: &[&str] = &[
14 "reading",
15 "writing",
16 "editing",
17 "searching",
18 "analyzing",
19 "creating",
20 "modifying",
21 "updating",
22 "checking",
23 "running",
24 "building",
25 "testing",
26 "fixing",
27 "implementing",
28 "refactoring",
29 "debugging",
30 "deploying",
31 "installing",
32 "configuring",
33 "deleting",
34 "removing",
35 "moving",
36 "renaming",
37 "copying",
38 "downloading",
39 "uploading",
40 "parsing",
41 "compiling",
42 "formatting",
43 "linting",
44 "reviewing",
45 "examining",
46 "looking",
47 "inspecting",
48 "exploring",
49 "scanning",
50 "fetching",
51 "loading",
52 "saving",
53 "committing",
54 "pushing",
55 "pulling",
56 "merging",
57 "rebasing",
58 "cloning",
59];
60
61const INTENT_PREFIXES: &[&str] = &[
63 "I'll ",
64 "I will ",
65 "Let me ",
66 "I need to ",
67 "I'm going to ",
68 "I am going to ",
69 "Now I'll ",
70 "Now let me ",
71 "First, I'll ",
72 "Next, I'll ",
73];
74
75pub fn summarize_action(text: &str, max_length: usize) -> String {
79 let max_len = if max_length == 0 {
80 DEFAULT_MAX_LENGTH
81 } else {
82 max_length
83 };
84
85 if let Some(summary) = extract_from_intent(text) {
87 return truncate_to(&summary, max_len);
88 }
89
90 if let Some(summary) = extract_action_verb_sentence(text) {
92 return truncate_to(&summary, max_len);
93 }
94
95 let first = first_sentence(text);
97 truncate_to(&first, max_len)
98}
99
100fn extract_from_intent(text: &str) -> Option<String> {
102 for prefix in INTENT_PREFIXES {
103 if let Some(rest) = text.strip_prefix(prefix) {
104 let sentence = first_clause(rest);
105 if sentence.is_empty() {
106 continue;
107 }
108 let converted = verb_to_gerund(&sentence);
110 return Some(capitalize_first(&converted));
111 }
112 }
113 None
114}
115
116fn extract_action_verb_sentence(text: &str) -> Option<String> {
118 let lower = text.to_lowercase();
119 for verb in ACTION_VERBS {
120 if let Some(pos) = lower.find(verb) {
121 if pos > 0 {
123 let before = text.as_bytes()[pos - 1];
124 if before != b'\n' && before != b'.' && before != b' ' {
125 continue;
126 }
127 }
128 let rest = &text[pos..];
129 let sentence = first_clause(rest);
130 return Some(capitalize_first(&sentence));
131 }
132 }
133 None
134}
135
136fn verb_to_gerund(text: &str) -> String {
138 let words: Vec<&str> = text.splitn(2, char::is_whitespace).collect();
139 if words.is_empty() {
140 return text.to_string();
141 }
142
143 let verb = words[0].to_lowercase();
144 let rest = if words.len() > 1 { words[1] } else { "" };
145
146 if verb.ends_with("ing") {
148 return text.to_string();
149 }
150
151 let last_char = verb.chars().last();
152 let second_last = verb.chars().nth(verb.len().saturating_sub(2));
153 let third_last = verb.chars().nth(verb.len().saturating_sub(3));
154
155 let gerund = if verb.ends_with('e') && !verb.ends_with("ee") {
156 format!("{}ing", &verb[..verb.len() - 1])
157 } else if verb.len() >= 3
158 && last_char.is_some_and(is_consonant)
159 && second_last.is_some_and(is_vowel)
160 && third_last.is_some_and(is_consonant)
161 && !verb.ends_with('w')
162 && !verb.ends_with('x')
163 && !verb.ends_with('y')
164 {
165 format!("{}{}", verb, last_char.unwrap_or_default()) + "ing"
167 } else {
168 format!("{verb}ing")
169 };
170
171 if rest.is_empty() {
172 gerund
173 } else {
174 format!("{gerund} {rest}")
175 }
176}
177
178fn is_vowel(c: char) -> bool {
179 matches!(c, 'a' | 'e' | 'i' | 'o' | 'u')
180}
181
182fn is_consonant(c: char) -> bool {
183 c.is_ascii_alphabetic() && !is_vowel(c)
184}
185
186fn capitalize_first(s: &str) -> String {
187 let mut chars = s.chars();
188 match chars.next() {
189 Some(c) => c.to_uppercase().to_string() + chars.as_str(),
190 None => String::new(),
191 }
192}
193
194fn first_sentence(text: &str) -> Cow<'_, str> {
195 let line = text.lines().next().unwrap_or(text);
196 if let Some(pos) = line.find(['.', '!', '?']) {
197 Cow::Borrowed(&line[..pos])
198 } else {
199 Cow::Borrowed(line)
200 }
201}
202
203fn first_clause(text: &str) -> String {
204 let line = text.lines().next().unwrap_or(text);
205 let end = line.find(['.', ';', '—']).unwrap_or(line.len());
207 line[..end].trim().to_string()
208}
209
210fn truncate_to(s: &str, max_len: usize) -> String {
211 if s.len() <= max_len {
212 s.to_string()
213 } else {
214 format!("{}...", &s[..max_len - 3])
215 }
216}
217
218#[cfg(test)]
219#[path = "action_summarizer_tests.rs"]
220mod tests;