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