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