lean_ctx/core/
auto_capture.rs1use crate::core::auto_findings::AutoFinding;
8use crate::core::knowledge::ProjectKnowledge;
9
10pub fn is_enabled() -> bool {
12 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CAPTURE") {
13 return matches!(v.trim(), "1" | "true" | "on");
14 }
15 crate::core::config::Config::load().auto_capture
16}
17
18pub fn capture_finding(project_root: &str, finding: &AutoFinding) {
20 if !is_enabled() {
21 return;
22 }
23
24 let category = classify_category(&finding.summary);
25 let key = derive_key(finding);
26 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
27
28 let Ok(policy) = crate::core::config::Config::load().memory_policy_effective() else {
29 return;
30 };
31
32 knowledge.remember(
33 &category,
34 &key,
35 &finding.summary,
36 "auto-capture",
37 0.6,
38 &policy,
39 );
40 let _ = knowledge.save();
41}
42
43fn classify_category(summary: &str) -> String {
44 let s = summary.to_lowercase();
45 if s.contains("error") || s.contains("fail") || s.contains("panic") {
46 "blocker".to_string()
47 } else if s.contains("test") || s.contains("assert") {
48 "pattern".to_string()
49 } else if s.contains("config") || s.contains("setting") {
50 "decision".to_string()
51 } else {
52 "finding".to_string()
53 }
54}
55
56fn derive_key(finding: &AutoFinding) -> String {
57 if let Some(ref file) = finding.file {
58 let short = file.rsplit('/').next().unwrap_or(file);
59 format!("auto:{short}")
60 } else {
61 let first_word = finding.summary.split_whitespace().next().unwrap_or("item");
62 format!("auto:{first_word}")
63 }
64}
65
66pub fn extract_extra(tool_name: &str, output: &str) -> Option<AutoFinding> {
68 match tool_name {
69 "ctx_edit" | "ctx_multi_edit" => extract_edit_finding(output),
70 "ctx_diff" => extract_diff_finding(output),
71 _ => None,
72 }
73}
74
75fn extract_edit_finding(output: &str) -> Option<AutoFinding> {
76 let first_line = output.lines().next()?;
77 if first_line.contains("Applied") || first_line.contains("✓") {
78 let file = first_line
79 .split_whitespace()
80 .find(|w| w.contains('/') || w.contains('.'))
81 .map(|s| {
82 s.trim_matches(|c: char| {
83 !c.is_alphanumeric() && c != '/' && c != '.' && c != '_' && c != '-'
84 })
85 .to_string()
86 });
87 Some(AutoFinding {
88 file,
89 summary: truncate(first_line, 120),
90 })
91 } else {
92 None
93 }
94}
95
96fn extract_diff_finding(output: &str) -> Option<AutoFinding> {
97 let lines: Vec<&str> = output.lines().take(5).collect();
98 if lines.is_empty() {
99 return None;
100 }
101
102 let added = output
103 .lines()
104 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
105 .count();
106 let removed = output
107 .lines()
108 .filter(|l| l.starts_with('-') && !l.starts_with("---"))
109 .count();
110
111 if added + removed == 0 {
112 return None;
113 }
114
115 let file = lines
116 .iter()
117 .find(|l| l.starts_with("--- ") || l.starts_with("+++ "))
118 .and_then(|l| l.split_whitespace().nth(1))
119 .map(std::string::ToString::to_string);
120
121 Some(AutoFinding {
122 file,
123 summary: format!("+{added}/-{removed} lines changed"),
124 })
125}
126
127fn truncate(s: &str, max: usize) -> String {
128 if s.len() <= max {
129 s.to_string()
130 } else {
131 format!("{}...", &s[..s.floor_char_boundary(max)])
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn classify_error_category() {
141 assert_eq!(classify_category("compilation error in build"), "blocker");
142 }
143
144 #[test]
145 fn classify_pattern_category() {
146 assert_eq!(classify_category("test suite passed 42 tests"), "pattern");
147 }
148
149 #[test]
150 fn classify_decision_category() {
151 assert_eq!(classify_category("config option added"), "decision");
152 }
153
154 #[test]
155 fn classify_finding_default() {
156 assert_eq!(classify_category("read file main.rs"), "finding");
157 }
158
159 #[test]
160 fn derive_key_with_file() {
161 let f = AutoFinding {
162 file: Some("src/core/config.rs".into()),
163 summary: "something".into(),
164 };
165 assert_eq!(derive_key(&f), "auto:config.rs");
166 }
167
168 #[test]
169 fn derive_key_without_file() {
170 let f = AutoFinding {
171 file: None,
172 summary: "compilation error".into(),
173 };
174 assert_eq!(derive_key(&f), "auto:compilation");
175 }
176
177 #[test]
178 fn extract_edit_result() {
179 let output = "✓ Applied to src/main.rs (3 replacements)";
180 let finding = extract_edit_finding(output);
181 assert!(finding.is_some());
182 }
183
184 #[test]
185 fn extract_diff_counts() {
186 let output = "--- a/file.rs\n+++ b/file.rs\n-old line\n+new line\n+another";
187 let finding = extract_diff_finding(output);
188 assert!(finding.is_some());
189 let summary = finding.unwrap().summary;
190 assert!(summary.contains("+2/-1"), "expected +2/-1 got: {summary}");
191 }
192}