lean_ctx/core/gotcha_tracker/
learn.rs1use super::model::{Gotcha, GotchaStore};
2
3pub struct Learning {
5 pub category: String,
6 pub trigger: String,
7 pub resolution: String,
8 pub confidence: f32,
9 pub occurrences: u32,
10 pub sessions: usize,
11}
12
13impl std::fmt::Display for Learning {
14 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15 write!(
16 f,
17 "[{cat}] {trigger} → {res} (confidence: {conf:.0}%, seen {occ}x across {sess} sessions)",
18 cat = self.category,
19 trigger = self.trigger,
20 res = self.resolution,
21 conf = self.confidence * 100.0,
22 occ = self.occurrences,
23 sess = self.sessions,
24 )
25 }
26}
27
28const MIN_CONFIDENCE: f32 = 0.5;
29const MIN_OCCURRENCES: u32 = 2;
30
31pub fn extract_learnings(store: &GotchaStore) -> Vec<Learning> {
33 store
34 .gotchas
35 .iter()
36 .filter(|g| g.confidence >= MIN_CONFIDENCE && g.occurrences >= MIN_OCCURRENCES)
37 .map(gotcha_to_learning)
38 .collect()
39}
40
41fn gotcha_to_learning(g: &Gotcha) -> Learning {
42 Learning {
43 category: g.category.short_label().to_string(),
44 trigger: g.trigger.clone(),
45 resolution: g.resolution.clone(),
46 confidence: g.confidence,
47 occurrences: g.occurrences,
48 sessions: g.session_ids.len(),
49 }
50}
51
52const AGENTS_MARKER_START: &str = "<!-- lean-ctx-learn-start -->";
53const AGENTS_MARKER_END: &str = "<!-- lean-ctx-learn-end -->";
54
55pub fn format_agents_section(learnings: &[Learning]) -> String {
57 if learnings.is_empty() {
58 return String::new();
59 }
60
61 let mut out = String::new();
62 out.push_str(AGENTS_MARKER_START);
63 out.push('\n');
64 out.push_str("## Learned Gotchas (auto-generated by `lean-ctx learn`)\n\n");
65 out.push_str("Do NOT edit this section manually — it is overwritten on each `lean-ctx learn --apply`.\n\n");
66
67 for l in learnings {
68 out.push_str(&format!(
69 "- **[{cat}]** {trigger}\n → {res}\n",
70 cat = l.category,
71 trigger = l.trigger,
72 res = l.resolution,
73 ));
74 }
75 out.push_str(AGENTS_MARKER_END);
76 out.push('\n');
77 out
78}
79
80fn merge_marker_section(existing: &str, section: &str, title: &str) -> String {
84 if existing.contains(AGENTS_MARKER_START) {
85 let before = existing
86 .split(AGENTS_MARKER_START)
87 .next()
88 .unwrap_or(existing);
89 let after = existing.split(AGENTS_MARKER_END).nth(1).unwrap_or("");
90 format!(
91 "{}\n\n{}",
92 before.trim_end(),
93 section.trim_end().to_owned() + after
94 )
95 } else if existing.is_empty() {
96 format!("# {title}\n\n{section}")
97 } else {
98 format!("{}\n\n{section}", existing.trim_end())
99 }
100}
101
102fn apply_to_memory_file(
108 path: &std::path::Path,
109 section: &str,
110 create_if_missing: bool,
111) -> Result<bool, String> {
112 let exists = path.exists();
113 if !exists && !create_if_missing {
114 return Ok(false);
115 }
116 let existing = if exists {
117 std::fs::read_to_string(path)
118 .map_err(|e| format!("Failed to read {}: {e}", path.display()))?
119 } else {
120 String::new()
121 };
122 let title = path
123 .file_name()
124 .and_then(|n| n.to_str())
125 .unwrap_or("AGENTS.md");
126 let updated = merge_marker_section(&existing, section, title);
127 crate::config_io::write_atomic(path, &updated)
128 .map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
129 Ok(true)
130}
131
132pub fn apply_learnings(project_root: &str, learnings: &[Learning]) -> Result<Vec<String>, String> {
138 let section = format_agents_section(learnings);
139 if section.is_empty() {
140 return Ok(Vec::new());
141 }
142 let root = std::path::Path::new(project_root);
143 let mut written = Vec::new();
144 if apply_to_memory_file(&root.join("AGENTS.md"), §ion, true)? {
145 written.push("AGENTS.md".to_string());
146 }
147 if apply_to_memory_file(&root.join("CLAUDE.local.md"), §ion, false)? {
148 written.push("CLAUDE.local.md".to_string());
149 }
150 Ok(written)
151}
152
153pub fn apply_to_agents_md(project_root: &str, learnings: &[Learning]) -> Result<String, String> {
156 let section = format_agents_section(learnings);
157 if section.is_empty() {
158 return Ok("No learnings to write (need >=2 occurrences with >=50% confidence).".into());
159 }
160 let path = std::path::Path::new(project_root).join("AGENTS.md");
161 apply_to_memory_file(&path, §ion, true)?;
162 Ok(format!(
163 "Wrote {} learnings to {}",
164 learnings.len(),
165 path.display()
166 ))
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 fn sample() -> Vec<Learning> {
174 vec![Learning {
175 category: "Build".into(),
176 trigger: "cargo E0507".into(),
177 resolution: "clone before the move".into(),
178 confidence: 0.9,
179 occurrences: 3,
180 sessions: 2,
181 }]
182 }
183
184 #[test]
185 fn apply_learnings_creates_agents_and_is_idempotent() {
186 let dir = tempfile::tempdir().unwrap();
187 let root = dir.path().to_string_lossy().to_string();
188
189 let written = apply_learnings(&root, &sample()).unwrap();
190 assert_eq!(written, vec!["AGENTS.md".to_string()]);
191
192 let agents = dir.path().join("AGENTS.md");
193 let body = std::fs::read_to_string(&agents).unwrap();
194 assert!(body.contains("cargo E0507"));
195 assert!(body.contains(AGENTS_MARKER_START));
196
197 apply_learnings(&root, &sample()).unwrap();
199 let body2 = std::fs::read_to_string(&agents).unwrap();
200 assert_eq!(
201 body2.matches(AGENTS_MARKER_START).count(),
202 1,
203 "the marker section must be replaced, never duplicated"
204 );
205 }
206
207 #[test]
208 fn apply_learnings_updates_claude_local_only_when_present() {
209 let dir = tempfile::tempdir().unwrap();
210 let root = dir.path().to_string_lossy().to_string();
211 let claude = dir.path().join("CLAUDE.local.md");
212 std::fs::write(&claude, "# My notes\n\nkeep this line\n").unwrap();
213
214 let written = apply_learnings(&root, &sample()).unwrap();
215 assert!(written.contains(&"AGENTS.md".to_string()));
216 assert!(written.contains(&"CLAUDE.local.md".to_string()));
217
218 let body = std::fs::read_to_string(&claude).unwrap();
219 assert!(body.contains("keep this line"), "user content is preserved");
220 assert!(body.contains("cargo E0507"), "learnings are injected");
221 }
222
223 #[test]
224 fn apply_learnings_skips_absent_claude_local() {
225 let dir = tempfile::tempdir().unwrap();
226 let root = dir.path().to_string_lossy().to_string();
227
228 let written = apply_learnings(&root, &sample()).unwrap();
229 assert_eq!(
230 written,
231 vec!["AGENTS.md".to_string()],
232 "CLAUDE.local.md is never created unsolicited"
233 );
234 assert!(!dir.path().join("CLAUDE.local.md").exists());
235 }
236
237 #[test]
238 fn apply_learnings_empty_writes_nothing() {
239 let dir = tempfile::tempdir().unwrap();
240 let root = dir.path().to_string_lossy().to_string();
241 let written = apply_learnings(&root, &[]).unwrap();
242 assert!(written.is_empty());
243 assert!(!dir.path().join("AGENTS.md").exists());
244 }
245}