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