use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use regex::Regex;
use super::normalize_error_signature;
#[must_use]
pub fn default_transcript_dir() -> Option<PathBuf> {
let home = dirs::home_dir()?;
[
home.join(".claude").join("projects"),
home.join(".cursor").join("agent-transcripts"),
]
.into_iter()
.find(|p| p.is_dir())
}
#[derive(Debug, Clone, PartialEq)]
pub struct MinedSignature {
pub signature: String,
pub occurrences: usize,
pub sessions: usize,
}
fn signature_patterns() -> Vec<Regex> {
[
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]*", ]
.iter()
.filter_map(|p| Regex::new(p).ok())
.collect()
}
pub fn extract_error_signatures(text: &str) -> Vec<String> {
extract_with(text, &signature_patterns())
}
fn extract_with(text: &str, patterns: &[Regex]) -> Vec<String> {
let mut out = Vec::new();
for re in patterns {
for m in re.find_iter(text) {
let sig = normalize_error_signature(m.as_str());
if !sig.is_empty() {
out.push(sig);
}
}
}
out
}
fn collect_strings(value: &serde_json::Value, out: &mut String) {
match value {
serde_json::Value::String(s) => {
out.push_str(s);
out.push('\n');
}
serde_json::Value::Array(arr) => {
for v in arr {
collect_strings(v, out);
}
}
serde_json::Value::Object(map) => {
for v in map.values() {
collect_strings(v, out);
}
}
_ => {}
}
}
pub fn mine_jsonl_dir(dir: &Path) -> Vec<MinedSignature> {
let patterns = signature_patterns();
let mut files: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
Ok(rd) => rd
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "jsonl"))
.collect(),
Err(_) => return Vec::new(),
};
files.sort();
let mut occ: BTreeMap<String, usize> = BTreeMap::new();
let mut sess: BTreeMap<String, usize> = BTreeMap::new();
for path in &files {
let Ok(content) = std::fs::read_to_string(path) else {
continue;
};
let mut seen_in_file: std::collections::BTreeSet<String> =
std::collections::BTreeSet::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let text = match serde_json::from_str::<serde_json::Value>(line) {
Ok(v) => {
let mut buf = String::new();
collect_strings(&v, &mut buf);
buf
}
Err(_) => line.to_string(),
};
for sig in extract_with(&text, &patterns) {
*occ.entry(sig.clone()).or_insert(0) += 1;
seen_in_file.insert(sig);
}
}
for sig in seen_in_file {
*sess.entry(sig).or_insert(0) += 1;
}
}
let mut result: Vec<MinedSignature> = occ
.into_iter()
.map(|(signature, occurrences)| {
let sessions = sess.get(&signature).copied().unwrap_or(0);
MinedSignature {
signature,
occurrences,
sessions,
}
})
.collect();
result.sort_by(|a, b| {
b.sessions
.cmp(&a.sessions)
.then_with(|| b.occurrences.cmp(&a.occurrences))
.then_with(|| a.signature.cmp(&b.signature))
});
result
}
pub fn format_mining_report(mined: &[MinedSignature], min_sessions: usize) -> String {
let recurring: Vec<&MinedSignature> = mined
.iter()
.filter(|m| m.sessions >= min_sessions)
.collect();
if recurring.is_empty() {
return "No recurring error signatures found in the mined transcripts.".to_string();
}
let mut out = format!(
"=== Recurring errors across sessions ({} signature(s)) ===\n",
recurring.len()
);
for m in recurring {
out.push_str(&format!(
" [{} sessions, {}x] {}\n",
m.sessions, m.occurrences, m.signature
));
}
out.push_str("\nThese recur across past sessions — capture fixes with ctx_knowledge or address the root cause.\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_known_error_markers() {
let text = "thread 'main' panicked at src/x.rs:1:1\n\
error[E0382]: borrow of moved value: `x`\n\
src/a.ts:3:1 - error TS2304: Cannot find name 'foo'.\n\
ModuleNotFoundError: No module named 'flask'";
let sigs = extract_error_signatures(text);
assert!(sigs.iter().any(|s| s.contains("E0382")));
assert!(sigs.iter().any(|s| s.contains("TS2304")));
assert!(sigs.iter().any(|s| s.contains("panicked at")));
assert!(sigs.iter().any(|s| s.contains("ModuleNotFoundError")));
}
#[test]
fn ignores_prose_without_markers() {
let text = "We discussed the error handling strategy and how to fix the failed build.";
assert!(
extract_error_signatures(text).is_empty(),
"discussion about errors must not match"
);
}
#[test]
fn mines_recurring_signature_across_files() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("s1.jsonl"),
"{\"role\":\"assistant\",\"content\":\"error[E0382]: borrow of moved value: x\"}\n",
)
.unwrap();
std::fs::write(
dir.path().join("s2.jsonl"),
"{\"role\":\"user\",\"text\":\"error[E0382]: borrow of moved value: x\"}\n",
)
.unwrap();
std::fs::write(dir.path().join("notes.txt"), "error[E9999]: ignore me").unwrap();
let mined = mine_jsonl_dir(dir.path());
let top = mined
.iter()
.find(|m| m.signature.contains("E0382"))
.expect("E0382 mined");
assert_eq!(top.sessions, 2, "seen across both transcript files");
assert_eq!(top.occurrences, 2);
assert!(
!mined.iter().any(|m| m.signature.contains("E9999")),
"non-.jsonl files are not mined"
);
let report = format_mining_report(&mined, 2);
assert!(report.contains("E0382"));
assert!(report.contains("2 sessions"));
}
#[test]
fn report_hides_one_off_signatures() {
let mined = vec![MinedSignature {
signature: "error[E0001]: once".to_string(),
occurrences: 1,
sessions: 1,
}];
let report = format_mining_report(&mined, 2);
assert!(report.contains("No recurring error signatures"));
}
#[test]
fn mining_is_deterministic() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("a.jsonl"),
"{\"content\":\"error[E0382]: moved\"}\n{\"content\":\"error TS2304: missing\"}\n",
)
.unwrap();
assert_eq!(mine_jsonl_dir(dir.path()), mine_jsonl_dir(dir.path()));
}
}