Skip to main content

lean_ctx/core/
prospective_memory.rs

1use 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; // 0..1
40        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        // Persist gotchas where GotchaStore::load expects them.
153        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}