1use crate::conversation::ConversationEntry;
7
8#[derive(Debug, Clone, Default)]
10pub struct ConversationTags {
11 pub decisions: Vec<String>,
12 pub action_items: Vec<String>,
13 pub project: Option<String>,
14 pub files_touched: Vec<String>,
15 pub tools_used: Vec<String>,
16}
17
18impl ConversationTags {
19 pub fn is_empty(&self) -> bool {
20 self.decisions.is_empty()
21 && self.action_items.is_empty()
22 && self.project.is_none()
23 && self.files_touched.is_empty()
24 && self.tools_used.is_empty()
25 }
26}
27
28const DECISION_MARKERS: &[&str] = &[
29 "decided to",
30 "decision:",
31 "we'll go with",
32 "going with",
33 "let's use",
34 "chose to",
35 "choosing",
36 "settled on",
37 "agreed on",
38 "switched to",
39 "instead of",
40 "rather than",
41 "the approach is",
42 "plan is to",
43];
44
45const ACTION_MARKERS: &[&str] = &[
46 "todo:",
47 "todo -",
48 "action item:",
49 "next step:",
50 "need to",
51 "needs to",
52 "should be",
53 "will need",
54 "follow up",
55 "follow-up",
56 "remaining:",
57 "still need",
58 "don't forget",
59 "remember to",
60 "make sure to",
61];
62
63pub fn extract_tags(entries: &[ConversationEntry]) -> ConversationTags {
65 let mut tags = ConversationTags::default();
66 let mut tool_set = std::collections::HashSet::new();
67 let mut file_set = std::collections::HashSet::new();
68
69 for entry in entries {
70 match entry {
71 ConversationEntry::UserMessage(text) | ConversationEntry::AssistantText(text) => {
72 extract_decisions(text, &mut tags.decisions);
73 extract_action_items(text, &mut tags.action_items);
74 if tags.project.is_none() {
75 tags.project = detect_project(text);
76 }
77 }
78 ConversationEntry::ToolUse {
79 name,
80 input_summary,
81 } => {
82 tool_set.insert(name.clone());
83 let summary = input_summary.trim_matches('`');
84 if !summary.is_empty() && (summary.contains('/') || summary.contains('.')) {
85 file_set.insert(summary.to_string());
86 }
87 }
88 ConversationEntry::ToolResult { .. } => {}
89 }
90 }
91
92 tags.tools_used = tool_set.into_iter().collect();
93 tags.tools_used.sort();
94 tags.files_touched = file_set.into_iter().collect();
95 tags.files_touched.sort();
96
97 tags.decisions.truncate(5);
98 tags.action_items.truncate(5);
99 tags.files_touched.truncate(10);
100
101 tags
102}
103
104fn extract_decisions(text: &str, decisions: &mut Vec<String>) {
105 let lower = text.to_lowercase();
106 for marker in DECISION_MARKERS {
107 if let Some(pos) = lower.find(marker) {
108 let start = text[..pos].rfind(['.', '\n']).map(|p| p + 1).unwrap_or(pos);
109 let end_offset = pos + marker.len();
110 let end = text[end_offset..]
111 .find(['.', '\n'])
112 .map(|p| end_offset + p + 1)
113 .unwrap_or(text.len().min(end_offset + 100));
114 let sentence = text[start..end].trim();
115 if sentence.len() >= 10 && sentence.len() <= 200 && decisions.len() < 5 {
116 let sentence_lower = sentence.to_lowercase();
117 if !decisions.iter().any(|d| d.to_lowercase() == sentence_lower) {
118 decisions.push(sentence.to_string());
119 }
120 }
121 }
122 }
123}
124
125fn extract_action_items(text: &str, actions: &mut Vec<String>) {
126 let lower = text.to_lowercase();
127 for marker in ACTION_MARKERS {
128 if let Some(pos) = lower.find(marker) {
129 let end_offset = pos + marker.len();
130 let end = text[end_offset..]
131 .find(['.', '\n'])
132 .map(|p| end_offset + p + 1)
133 .unwrap_or(text.len().min(end_offset + 100));
134 let item = text[pos..end].trim();
135 if item.len() >= 5 && item.len() <= 200 && actions.len() < 5 {
136 let item_lower = item.to_lowercase();
137 if !actions.iter().any(|a| a.to_lowercase() == item_lower) {
138 actions.push(item.to_string());
139 }
140 }
141 }
142 }
143}
144
145fn detect_project(text: &str) -> Option<String> {
146 let patterns = ["project:", "repo:", "repository:", "working on", "in the"];
147 let lower = text.to_lowercase();
148
149 for pattern in &patterns {
150 if let Some(pos) = lower.find(pattern) {
151 let after = &text[pos + pattern.len()..];
152 let word: String = after
153 .trim()
154 .chars()
155 .take_while(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
156 .collect();
157 if word.len() >= 2 {
158 return Some(word);
159 }
160 }
161 }
162
163 None
164}
165
166pub fn format_tags_section(tags: &ConversationTags) -> String {
168 if tags.is_empty() {
169 return String::new();
170 }
171
172 let mut section = String::from("\n## Tags\n");
173
174 if let Some(ref project) = tags.project {
175 section.push_str(&format!("\n**Project**: {project}\n"));
176 }
177
178 if !tags.decisions.is_empty() {
179 section.push_str("\n**Decisions**:\n");
180 for d in &tags.decisions {
181 section.push_str(&format!("- {d}\n"));
182 }
183 }
184
185 if !tags.action_items.is_empty() {
186 section.push_str("\n**Action Items**:\n");
187 for a in &tags.action_items {
188 section.push_str(&format!("- {a}\n"));
189 }
190 }
191
192 if !tags.files_touched.is_empty() {
193 section.push_str("\n**Files**: ");
194 section.push_str(&tags.files_touched.join(", "));
195 section.push('\n');
196 }
197
198 if !tags.tools_used.is_empty() {
199 section.push_str("\n**Tools**: ");
200 section.push_str(&tags.tools_used.join(", "));
201 section.push('\n');
202 }
203
204 section
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn extract_decisions_basic() {
213 let entries = vec![ConversationEntry::AssistantText(
214 "After reviewing the options, I decided to use JWT tokens instead of session cookies."
215 .to_string(),
216 )];
217 let tags = extract_tags(&entries);
218 assert!(!tags.decisions.is_empty());
219 assert!(tags.decisions[0].contains("JWT"));
220 }
221
222 #[test]
223 fn extract_action_items_basic() {
224 let entries = vec![ConversationEntry::AssistantText(
225 "The auth module works now. Still need to add rate limiting to the API endpoints."
226 .to_string(),
227 )];
228 let tags = extract_tags(&entries);
229 assert!(!tags.action_items.is_empty());
230 assert!(tags.action_items[0].contains("rate limiting"));
231 }
232
233 #[test]
234 fn extract_tools_and_files() {
235 let entries = vec![
236 ConversationEntry::ToolUse {
237 name: "Read".to_string(),
238 input_summary: "/src/auth.rs".to_string(),
239 },
240 ConversationEntry::ToolUse {
241 name: "Edit".to_string(),
242 input_summary: "/src/config.rs".to_string(),
243 },
244 ConversationEntry::ToolUse {
245 name: "Read".to_string(),
246 input_summary: "/src/main.rs".to_string(),
247 },
248 ];
249 let tags = extract_tags(&entries);
250 assert_eq!(tags.tools_used, vec!["Edit", "Read"]);
251 assert_eq!(tags.files_touched.len(), 3);
252 }
253
254 #[test]
255 fn empty_entries_empty_tags() {
256 let tags = extract_tags(&[]);
257 assert!(tags.is_empty());
258 }
259
260 #[test]
261 fn format_tags_section_basic() {
262 let tags = ConversationTags {
263 decisions: vec!["Use JWT instead of sessions".to_string()],
264 action_items: vec!["Still need to add rate limiting".to_string()],
265 project: Some("voice-echo".to_string()),
266 files_touched: vec!["/src/auth.rs".to_string()],
267 tools_used: vec!["Edit".to_string(), "Read".to_string()],
268 };
269 let section = format_tags_section(&tags);
270 assert!(section.contains("## Tags"));
271 assert!(section.contains("**Project**: voice-echo"));
272 assert!(section.contains("**Decisions**:"));
273 assert!(section.contains("JWT"));
274 }
275
276 #[test]
277 fn format_empty_tags_returns_empty() {
278 let tags = ConversationTags::default();
279 assert!(format_tags_section(&tags).is_empty());
280 }
281}