lean_ctx/core/
prospective_memory.rs1use crate::core::gotcha_tracker::GotchaStore;
2
3pub fn reminders_for_task(project_root: &str, task: &str) -> Vec<String> {
4 let task_terms = tokenize(task);
5 if task_terms.is_empty() {
6 return Vec::new();
7 }
8
9 let store = GotchaStore::load(project_root);
10 if store.gotchas.is_empty() {
11 return Vec::new();
12 }
13
14 #[derive(Clone)]
15 struct Scored {
16 line: String,
17 score: f32,
18 }
19
20 let mut scored: Vec<Scored> = Vec::new();
21
22 for g in &store.gotchas {
23 let searchable = format!(
24 "{} {} {} {}",
25 g.trigger.to_lowercase(),
26 g.resolution.to_lowercase(),
27 g.tags.join(" ").to_lowercase(),
28 g.category.short_label().to_lowercase()
29 );
30 let matches = task_terms
31 .iter()
32 .filter(|t| searchable.contains(*t))
33 .count();
34 if matches == 0 {
35 continue;
36 }
37 let rel = matches as f32 / task_terms.len() as f32;
38 let sev = g.severity.multiplier();
39 let rec = (g.prevented_count as f32).ln_1p().min(3.0) / 3.0; let score = rel * g.confidence * sev * (1.0 + rec * 0.2);
41
42 let mut line = format!(
43 "gotcha: {} → {}",
44 sanitize_one_line(&g.trigger),
45 sanitize_one_line(&g.resolution)
46 );
47 line = truncate_chars(&line, crate::core::budgets::PROSPECTIVE_REMINDER_MAX_CHARS);
48 scored.push(Scored { line, score });
49 }
50
51 scored.sort_by(|a, b| {
52 b.score
53 .partial_cmp(&a.score)
54 .unwrap_or(std::cmp::Ordering::Equal)
55 .then_with(|| a.line.cmp(&b.line))
56 });
57
58 scored
59 .into_iter()
60 .take(crate::core::budgets::PROSPECTIVE_REMINDERS_LIMIT)
61 .map(|s| format!("[remember] {}", s.line))
62 .collect()
63}
64
65fn tokenize(s: &str) -> Vec<String> {
66 let mut out = Vec::new();
67 let mut cur = String::new();
68 for ch in s.chars() {
69 if ch.is_ascii_alphanumeric() {
70 cur.push(ch.to_ascii_lowercase());
71 } else if !cur.is_empty() {
72 if cur.len() >= 3 {
73 out.push(cur.clone());
74 }
75 cur.clear();
76 }
77 }
78 if !cur.is_empty() && cur.len() >= 3 {
79 out.push(cur);
80 }
81 out.sort();
82 out.dedup();
83 out
84}
85
86fn sanitize_one_line(s: &str) -> String {
87 let mut t = s.replace(['\n', '\r'], " ");
88 t = t.replace('`', "");
89 while t.contains(" ") {
90 t = t.replace(" ", " ");
91 }
92 t.trim().to_string()
93}
94
95fn truncate_chars(s: &str, max: usize) -> String {
96 if s.chars().count() <= max {
97 return s.to_string();
98 }
99 let mut out = String::new();
100 for (i, ch) in s.chars().enumerate() {
101 if i + 1 >= max {
102 break;
103 }
104 out.push(ch);
105 }
106 out.push('…');
107 out
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113 use crate::core::gotcha_tracker::{Gotcha, GotchaCategory, GotchaSeverity, GotchaSource};
114 use chrono::Utc;
115
116 #[test]
117 fn reminders_budgeted() {
118 let _lock = crate::core::data_dir::test_env_lock();
119 let tmp = tempfile::tempdir().expect("tempdir");
120 std::env::set_var(
121 "LEAN_CTX_DATA_DIR",
122 tmp.path().to_string_lossy().to_string(),
123 );
124
125 let project_root = tmp.path().join("proj");
126 std::fs::create_dir_all(&project_root).expect("mkdir");
127 let project_root_str = project_root.to_string_lossy().to_string();
128
129 let mut store = GotchaStore::load(&project_root_str);
130 for i in 0..10 {
131 store.gotchas.push(Gotcha {
132 id: format!("g{i}"),
133 category: GotchaCategory::Build,
134 severity: GotchaSeverity::Warning,
135 trigger: format!("cargo build error E050{i}"),
136 resolution: "split borrows".to_string(),
137 file_patterns: vec![],
138 occurrences: 2,
139 session_ids: vec!["s1".to_string()],
140 first_seen: Utc::now(),
141 last_seen: Utc::now(),
142 confidence: 0.8,
143 source: GotchaSource::AutoDetected {
144 command: "cargo build".to_string(),
145 exit_code: 1,
146 },
147 prevented_count: 0,
148 tags: vec!["rust".to_string()],
149 });
150 }
151
152 store.save(&project_root_str).expect("save");
154
155 let reminders = reminders_for_task(&project_root_str, "fix cargo build error E0502 borrow");
156 assert!(reminders.len() <= crate::core::budgets::PROSPECTIVE_REMINDERS_LIMIT);
157
158 std::env::remove_var("LEAN_CTX_DATA_DIR");
159 }
160}