lean_ctx/core/gotcha_tracker/
mining.rs1use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18use regex::Regex;
19
20use super::normalize_error_signature;
21
22#[must_use]
26pub fn default_transcript_dir() -> Option<PathBuf> {
27 let home = dirs::home_dir()?;
28 [
29 home.join(".claude").join("projects"),
30 home.join(".cursor").join("agent-transcripts"),
31 ]
32 .into_iter()
33 .find(|p| p.is_dir())
34}
35
36#[derive(Debug, Clone, PartialEq)]
38pub struct MinedSignature {
39 pub signature: String,
40 pub occurrences: usize,
42 pub sessions: usize,
44}
45
46fn signature_patterns() -> Vec<Regex> {
50 [
51 r"error\[E\d{4}\][^\n]*", r"error TS\d{4}[^\n]*", r"panicked at [^\n]*", r"npm ERR![^\n]*", r"ModuleNotFoundError[^\n]*", r"ImportError: [^\n]*", r"undefined: [A-Za-z_][\w]*", r"undefined reference to [^\n]*", ]
60 .iter()
61 .filter_map(|p| Regex::new(p).ok())
62 .collect()
63}
64
65pub fn extract_error_signatures(text: &str) -> Vec<String> {
68 extract_with(text, &signature_patterns())
69}
70
71fn extract_with(text: &str, patterns: &[Regex]) -> Vec<String> {
72 let mut out = Vec::new();
73 for re in patterns {
74 for m in re.find_iter(text) {
75 let sig = normalize_error_signature(m.as_str());
76 if !sig.is_empty() {
77 out.push(sig);
78 }
79 }
80 }
81 out
82}
83
84fn collect_strings(value: &serde_json::Value, out: &mut String) {
88 match value {
89 serde_json::Value::String(s) => {
90 out.push_str(s);
91 out.push('\n');
92 }
93 serde_json::Value::Array(arr) => {
94 for v in arr {
95 collect_strings(v, out);
96 }
97 }
98 serde_json::Value::Object(map) => {
99 for v in map.values() {
100 collect_strings(v, out);
101 }
102 }
103 _ => {}
104 }
105}
106
107pub fn mine_jsonl_dir(dir: &Path) -> Vec<MinedSignature> {
111 let patterns = signature_patterns();
112
113 let mut files: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
115 Ok(rd) => rd
116 .flatten()
117 .map(|e| e.path())
118 .filter(|p| p.extension().is_some_and(|e| e == "jsonl"))
119 .collect(),
120 Err(_) => return Vec::new(),
121 };
122 files.sort();
123
124 let mut occ: BTreeMap<String, usize> = BTreeMap::new();
126 let mut sess: BTreeMap<String, usize> = BTreeMap::new();
127
128 for path in &files {
129 let Ok(content) = std::fs::read_to_string(path) else {
130 continue;
131 };
132 let mut seen_in_file: std::collections::BTreeSet<String> =
133 std::collections::BTreeSet::new();
134 for line in content.lines() {
135 let line = line.trim();
136 if line.is_empty() {
137 continue;
138 }
139 let text = match serde_json::from_str::<serde_json::Value>(line) {
142 Ok(v) => {
143 let mut buf = String::new();
144 collect_strings(&v, &mut buf);
145 buf
146 }
147 Err(_) => line.to_string(),
148 };
149 for sig in extract_with(&text, &patterns) {
150 *occ.entry(sig.clone()).or_insert(0) += 1;
151 seen_in_file.insert(sig);
152 }
153 }
154 for sig in seen_in_file {
155 *sess.entry(sig).or_insert(0) += 1;
156 }
157 }
158
159 let mut result: Vec<MinedSignature> = occ
160 .into_iter()
161 .map(|(signature, occurrences)| {
162 let sessions = sess.get(&signature).copied().unwrap_or(0);
163 MinedSignature {
164 signature,
165 occurrences,
166 sessions,
167 }
168 })
169 .collect();
170
171 result.sort_by(|a, b| {
172 b.sessions
173 .cmp(&a.sessions)
174 .then_with(|| b.occurrences.cmp(&a.occurrences))
175 .then_with(|| a.signature.cmp(&b.signature))
176 });
177 result
178}
179
180pub fn format_mining_report(mined: &[MinedSignature], min_sessions: usize) -> String {
183 let recurring: Vec<&MinedSignature> = mined
184 .iter()
185 .filter(|m| m.sessions >= min_sessions)
186 .collect();
187
188 if recurring.is_empty() {
189 return "No recurring error signatures found in the mined transcripts.".to_string();
190 }
191
192 let mut out = format!(
193 "=== Recurring errors across sessions ({} signature(s)) ===\n",
194 recurring.len()
195 );
196 for m in recurring {
197 out.push_str(&format!(
198 " [{} sessions, {}x] {}\n",
199 m.sessions, m.occurrences, m.signature
200 ));
201 }
202 out.push_str("\nThese recur across past sessions — capture fixes with ctx_knowledge or address the root cause.\n");
203 out
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn extracts_known_error_markers() {
212 let text = "thread 'main' panicked at src/x.rs:1:1\n\
213 error[E0382]: borrow of moved value: `x`\n\
214 src/a.ts:3:1 - error TS2304: Cannot find name 'foo'.\n\
215 ModuleNotFoundError: No module named 'flask'";
216 let sigs = extract_error_signatures(text);
217 assert!(sigs.iter().any(|s| s.contains("E0382")));
218 assert!(sigs.iter().any(|s| s.contains("TS2304")));
219 assert!(sigs.iter().any(|s| s.contains("panicked at")));
220 assert!(sigs.iter().any(|s| s.contains("ModuleNotFoundError")));
221 }
222
223 #[test]
224 fn ignores_prose_without_markers() {
225 let text = "We discussed the error handling strategy and how to fix the failed build.";
226 assert!(
227 extract_error_signatures(text).is_empty(),
228 "discussion about errors must not match"
229 );
230 }
231
232 #[test]
233 fn mines_recurring_signature_across_files() {
234 let dir = tempfile::tempdir().expect("tempdir");
235 std::fs::write(
237 dir.path().join("s1.jsonl"),
238 "{\"role\":\"assistant\",\"content\":\"error[E0382]: borrow of moved value: x\"}\n",
239 )
240 .unwrap();
241 std::fs::write(
242 dir.path().join("s2.jsonl"),
243 "{\"role\":\"user\",\"text\":\"error[E0382]: borrow of moved value: x\"}\n",
244 )
245 .unwrap();
246 std::fs::write(dir.path().join("notes.txt"), "error[E9999]: ignore me").unwrap();
248
249 let mined = mine_jsonl_dir(dir.path());
250 let top = mined
251 .iter()
252 .find(|m| m.signature.contains("E0382"))
253 .expect("E0382 mined");
254 assert_eq!(top.sessions, 2, "seen across both transcript files");
255 assert_eq!(top.occurrences, 2);
256 assert!(
257 !mined.iter().any(|m| m.signature.contains("E9999")),
258 "non-.jsonl files are not mined"
259 );
260
261 let report = format_mining_report(&mined, 2);
262 assert!(report.contains("E0382"));
263 assert!(report.contains("2 sessions"));
264 }
265
266 #[test]
267 fn report_hides_one_off_signatures() {
268 let mined = vec![MinedSignature {
269 signature: "error[E0001]: once".to_string(),
270 occurrences: 1,
271 sessions: 1,
272 }];
273 let report = format_mining_report(&mined, 2);
274 assert!(report.contains("No recurring error signatures"));
275 }
276
277 #[test]
278 fn mining_is_deterministic() {
279 let dir = tempfile::tempdir().expect("tempdir");
280 std::fs::write(
281 dir.path().join("a.jsonl"),
282 "{\"content\":\"error[E0382]: moved\"}\n{\"content\":\"error TS2304: missing\"}\n",
283 )
284 .unwrap();
285 assert_eq!(mine_jsonl_dir(dir.path()), mine_jsonl_dir(dir.path()));
286 }
287}