lean_ctx/core/
auto_capture.rs1use crate::core::auto_findings::AutoFinding;
8use crate::core::knowledge::ProjectKnowledge;
9
10pub(crate) 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(crate) fn capture_finding(project_root: &str, finding: &AutoFinding) {
20 capture_finding_from_tool(project_root, finding, "ctx_knowledge", &finding.summary);
21}
22
23pub fn capture_finding_from_tool(
24 project_root: &str,
25 finding: &AutoFinding,
26 source_tool: &str,
27 original_content: &str,
28) {
29 if !is_enabled() {
30 return;
31 }
32
33 let _ = ProjectKnowledge::with_project_lock_checked(project_root, || {
39 if !is_enabled() {
40 return;
41 }
42 let Ok(policy) = crate::core::config::Config::load().memory_policy_effective() else {
43 return;
44 };
45 let Ok(active) =
46 crate::core::policy::runtime::for_project(std::path::Path::new(project_root))
47 else {
48 return;
49 };
50 if active
51 .as_ref()
52 .is_some_and(|p| !p.tool_allowed("ctx_knowledge") || !p.tool_allowed(source_tool))
53 {
54 return;
55 }
56 if active.as_ref().is_some_and(|policy| {
57 crate::core::policy::content::evaluate_text(original_content, policy).blocked
58 }) {
59 return;
60 }
61 let Some(safe) = crate::core::policy::diagnostics::inspect(
62 &serde_json::json!({"file":finding.file,"summary":finding.summary}),
63 active.as_deref(),
64 ) else {
65 return;
66 };
67 let summary = safe["summary"].as_str().unwrap_or_default();
68 let category = classify_category(summary);
69 let key = derive_key(finding);
70 let key_needs_masking = active.as_ref().is_some_and(|active| {
71 crate::core::policy::content::evaluate_text(&key, active).text != key
72 });
73 let key = if key_needs_masking {
74 format!("auto:{}", blake3::hash(key.as_bytes()).to_hex())
75 } else {
76 key
77 };
78 let Ok(mut knowledge) = ProjectKnowledge::load_for_checked_capture(project_root) else {
79 return;
80 };
81 knowledge.remember(&category, &key, summary, "auto-capture", 0.6, &policy);
82 let _ = knowledge.save_policy_checked(source_tool, original_content);
83 });
84}
85
86fn classify_category(summary: &str) -> String {
87 let s = summary.to_lowercase();
88 if s.contains("solution-debt") || s.contains("solution_debt") {
89 "solution-debt".to_string()
90 } else if s.contains("solution-decision") || s.contains("solution_decision") {
91 "solution-decision".to_string()
92 } else if s.contains("error") || s.contains("fail") || s.contains("panic") {
93 "blocker".to_string()
94 } else if s.contains("test") || s.contains("assert") {
95 "pattern".to_string()
96 } else if s.contains("config") || s.contains("setting") {
97 "decision".to_string()
98 } else {
99 "finding".to_string()
100 }
101}
102
103fn derive_key(finding: &AutoFinding) -> String {
104 if let Some(ref file) = finding.file {
105 let short = file.rsplit('/').next().unwrap_or(file);
106 format!("auto:{short}")
107 } else {
108 let first_word = finding.summary.split_whitespace().next().unwrap_or("item");
109 format!("auto:{first_word}")
110 }
111}
112
113pub fn extract_extra(tool_name: &str, output: &str) -> Option<AutoFinding> {
115 match tool_name {
116 "ctx_edit" | "ctx_multi_edit" => extract_edit_finding(output),
117 "ctx_diff" => extract_diff_finding(output),
118 _ => None,
119 }
120}
121
122fn extract_edit_finding(output: &str) -> Option<AutoFinding> {
123 let first_line = output.lines().next()?;
124 if first_line.contains("Applied")
125 || first_line.contains("✓")
126 || first_line.starts_with("patched ")
127 {
128 let file = first_line
129 .split_whitespace()
130 .find(|w| w.contains('/') || w.contains('.'))
131 .map(|s| {
132 s.trim_matches(|c: char| {
133 !c.is_alphanumeric() && c != '/' && c != '.' && c != '_' && c != '-'
134 })
135 .to_string()
136 });
137 Some(AutoFinding {
138 file,
139 summary: truncate(first_line, 120),
140 })
141 } else {
142 None
143 }
144}
145
146fn extract_diff_finding(output: &str) -> Option<AutoFinding> {
147 let lines: Vec<&str> = output.lines().take(5).collect();
148 if lines.is_empty() {
149 return None;
150 }
151
152 let added = output
153 .lines()
154 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
155 .count();
156 let removed = output
157 .lines()
158 .filter(|l| l.starts_with('-') && !l.starts_with("---"))
159 .count();
160
161 if added + removed == 0 {
162 return None;
163 }
164
165 let file = lines
166 .iter()
167 .find(|l| l.starts_with("--- ") || l.starts_with("+++ "))
168 .and_then(|l| l.split_whitespace().nth(1))
169 .map(std::string::ToString::to_string);
170
171 Some(AutoFinding {
172 file,
173 summary: format!("+{added}/-{removed} lines changed"),
174 })
175}
176
177fn truncate(s: &str, max: usize) -> String {
178 if s.len() <= max {
179 s.to_string()
180 } else {
181 format!("{}...", &s[..s.floor_char_boundary(max)])
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn classify_solution_categories() {
191 assert_eq!(
192 classify_category("solution-decision: chose HashMap"),
193 "solution-decision"
194 );
195 assert_eq!(
196 classify_category("solution-debt: defer migration"),
197 "solution-debt"
198 );
199 }
200
201 #[test]
202 fn classify_error_category() {
203 assert_eq!(classify_category("compilation error in build"), "blocker");
204 }
205
206 #[test]
207 fn classify_pattern_category() {
208 assert_eq!(classify_category("test suite passed 42 tests"), "pattern");
209 }
210
211 #[test]
212 fn classify_decision_category() {
213 assert_eq!(classify_category("config option added"), "decision");
214 }
215
216 #[test]
217 fn classify_finding_default() {
218 assert_eq!(classify_category("read file main.rs"), "finding");
219 }
220
221 #[test]
222 fn derive_key_with_file() {
223 let f = AutoFinding {
224 file: Some("src/core/config.rs".into()),
225 summary: "something".into(),
226 };
227 assert_eq!(derive_key(&f), "auto:config.rs");
228 }
229
230 #[test]
231 fn derive_key_without_file() {
232 let f = AutoFinding {
233 file: None,
234 summary: "compilation error".into(),
235 };
236 assert_eq!(derive_key(&f), "auto:compilation");
237 }
238
239 #[test]
240 fn extract_edit_result() {
241 let output = "✓ Applied to src/main.rs (3 replacements)";
242 let finding = extract_edit_finding(output);
243 assert!(finding.is_some());
244 }
245
246 #[test]
247 fn extract_diff_counts() {
248 let output = "--- a/file.rs\n+++ b/file.rs\n-old line\n+new line\n+another";
249 let finding = extract_diff_finding(output);
250 assert!(finding.is_some());
251 let summary = finding.unwrap().summary;
252 assert!(summary.contains("+2/-1"), "expected +2/-1 got: {summary}");
253 }
254}