1use std::sync::Mutex;
2use std::time::Instant;
3
4pub struct AutoFinding {
5 pub file: Option<String>,
6 pub summary: String,
7}
8
9struct RecentEntry {
10 key: String,
11 at: Instant,
12}
13
14static RECENT: Mutex<Vec<RecentEntry>> = Mutex::new(Vec::new());
15const DEDUP_WINDOW_SECS: u64 = 60;
16const MAX_SUMMARY_LEN: usize = 120;
17
18pub fn extract(tool_name: &str, output: &str) -> Option<AutoFinding> {
21 let finding = match tool_name {
22 "ctx_read" => extract_ctx_read(output),
23 "ctx_search" => extract_ctx_search(output),
24 "ctx_shell" => extract_ctx_shell(output),
25 "ctx_graph" => extract_ctx_graph(output),
26 "ctx_semantic_search" => extract_ctx_semantic_search(output),
27 _ => None,
28 }?;
29
30 let dedup_key = format!(
31 "{}:{}",
32 finding.file.as_deref().unwrap_or(""),
33 &finding.summary[..finding.summary.len().min(80)]
34 );
35
36 if let Ok(mut recent) = RECENT.lock() {
37 let now = Instant::now();
38 recent.retain(|e| now.duration_since(e.at).as_secs() < DEDUP_WINDOW_SECS);
39
40 if recent.iter().any(|e| e.key == dedup_key) {
41 return None;
42 }
43 recent.push(RecentEntry {
44 key: dedup_key,
45 at: now,
46 });
47 }
48
49 Some(finding)
50}
51
52fn extract_ctx_read(output: &str) -> Option<AutoFinding> {
53 let first_line = output.lines().next().unwrap_or("");
54 if first_line.is_empty() || output.len() < 20 {
55 return None;
56 }
57
58 let raw_path = first_line
59 .split_whitespace()
60 .next()
61 .unwrap_or("")
62 .trim_end_matches([':', ']']);
63
64 let path = strip_cache_ref(raw_path);
65
66 if path.is_empty() || path.starts_with('[') || path.starts_with("ERROR") {
67 return None;
68 }
69
70 let line_count = first_line
72 .split_whitespace()
73 .find(|w| w.ends_with('L') && w[..w.len() - 1].parse::<usize>().is_ok())
74 .unwrap_or("");
75
76 let content_hint = extract_content_hint(output);
78
79 let short_path = shorten_path(path);
80 let summary = match (line_count.is_empty(), content_hint.is_empty()) {
81 (true, true) => format!("Read {short_path}"),
82 (false, true) => format!("Read {short_path} ({line_count})"),
83 (true, false) => truncate(
84 &format!("Read {short_path} — {content_hint}"),
85 MAX_SUMMARY_LEN,
86 ),
87 (false, false) => truncate(
88 &format!("Read {short_path} ({line_count}) — {content_hint}"),
89 MAX_SUMMARY_LEN,
90 ),
91 };
92
93 Some(AutoFinding {
94 file: Some(path.to_string()),
95 summary,
96 })
97}
98
99fn extract_ctx_search(output: &str) -> Option<AutoFinding> {
100 let lines: Vec<&str> = output.lines().collect();
101 if lines.is_empty() {
102 return None;
103 }
104
105 let last = lines.last().unwrap_or(&"");
106 if last.contains("0 matches") || last.contains("No matches") {
107 return None;
108 }
109
110 let pattern = extract_search_pattern(&lines);
112
113 let matched_files: Vec<&str> = lines
115 .iter()
116 .filter(|l| {
117 l.contains(':')
118 && !l.starts_with('[')
119 && !l.starts_with("pattern")
120 && !l.starts_with("Pattern")
121 })
122 .filter_map(|l| l.split(':').next())
123 .collect();
124
125 let mut unique_files: Vec<&str> = Vec::new();
127 for f in &matched_files {
128 if !unique_files.contains(f) {
129 unique_files.push(f);
130 }
131 }
132
133 let match_count = matched_files.len();
134 let file_count = unique_files.len();
135
136 if match_count == 0 && file_count == 0 {
137 return None;
138 }
139
140 let file_list: String = if unique_files.len() <= 3 {
142 unique_files
143 .iter()
144 .map(|f| shorten_path(f))
145 .collect::<Vec<_>>()
146 .join(", ")
147 } else {
148 let top3: Vec<String> = unique_files[..3].iter().map(|f| shorten_path(f)).collect();
149 format!("{} +{} more", top3.join(", "), unique_files.len() - 3)
150 };
151
152 let summary = truncate(
153 &format!("Found `{pattern}` in {file_count} files: {file_list}"),
154 MAX_SUMMARY_LEN,
155 );
156
157 Some(AutoFinding {
158 file: None,
159 summary,
160 })
161}
162
163fn extract_ctx_shell(output: &str) -> Option<AutoFinding> {
164 let lines: Vec<&str> = output.lines().collect();
165 let first_line = lines.first().unwrap_or(&"");
166
167 let cmd = lines
169 .iter()
170 .find(|l| l.starts_with("$ ") || l.starts_with("cmd:"))
171 .map_or("", |l| {
172 l.trim_start_matches("$ ").trim_start_matches("cmd:").trim()
173 });
174
175 if let Some(test_summary) = extract_test_result(&lines, cmd) {
177 return Some(AutoFinding {
178 file: None,
179 summary: test_summary,
180 });
181 }
182
183 if let Some(build_summary) = extract_build_result(&lines, cmd) {
185 return Some(AutoFinding {
186 file: None,
187 summary: build_summary,
188 });
189 }
190
191 if let Some(rest) = first_line.strip_prefix("exit:") {
193 let code = rest.split_whitespace().next().unwrap_or("?");
194 if code != "0" {
195 let short_cmd = &cmd[..cmd.len().min(50)];
196 let error_hint = lines
197 .iter()
198 .find(|l| l.contains("error") || l.contains("Error") || l.contains("FAILED"))
199 .map_or("", |l| l.trim());
200 let error_short = &error_hint[..error_hint.len().min(50)];
201
202 let summary = if error_short.is_empty() {
203 format!("FAILED (exit {code}): {short_cmd}")
204 } else {
205 truncate(
206 &format!("FAILED (exit {code}): {short_cmd} — {error_short}"),
207 MAX_SUMMARY_LEN,
208 )
209 };
210 return Some(AutoFinding {
211 file: None,
212 summary,
213 });
214 }
215 }
216
217 None
218}
219
220fn extract_ctx_graph(output: &str) -> Option<AutoFinding> {
221 let first_line = output.lines().next().unwrap_or("");
222
223 if first_line.starts_with("Files related to") || first_line.starts_with("No files depend") {
224 let file = first_line
225 .split_whitespace()
226 .last()
227 .unwrap_or("")
228 .trim_end_matches(':')
229 .trim_end_matches(|c: char| c == '(' || c.is_ascii_digit() || c == ')')
230 .to_string();
231
232 let count = first_line
233 .split('(')
234 .nth(1)
235 .and_then(|s| s.split(')').next())
236 .and_then(|s| s.parse::<usize>().ok())
237 .unwrap_or(0);
238
239 if count > 0 {
240 return Some(AutoFinding {
241 file: Some(file),
242 summary: first_line.to_string(),
243 });
244 }
245 }
246
247 None
248}
249
250fn extract_ctx_semantic_search(output: &str) -> Option<AutoFinding> {
251 let lines: Vec<&str> = output.lines().collect();
252 if lines.is_empty() {
253 return None;
254 }
255
256 let results: Vec<&&str> = lines
258 .iter()
259 .filter(|l| l.starts_with(" ") || l.contains("score:") || l.contains("→"))
260 .collect();
261
262 if results.is_empty() {
263 return None;
264 }
265
266 let query = lines
268 .first()
269 .and_then(|l| {
270 l.strip_prefix("query:")
271 .or_else(|| l.strip_prefix("Query:"))
272 })
273 .map_or("semantic search", str::trim);
274
275 let summary = truncate(
276 &format!("Semantic search `{}` — {} results", query, results.len()),
277 MAX_SUMMARY_LEN,
278 );
279
280 Some(AutoFinding {
281 file: None,
282 summary,
283 })
284}
285
286fn strip_cache_ref(raw: &str) -> &str {
289 if raw.len() > 3
290 && raw.starts_with('F')
291 && raw[1..].starts_with(|c: char| c.is_ascii_digit())
292 && raw.contains('=')
293 {
294 raw.split_once('=').map_or(raw, |(_, p)| p)
295 } else {
296 raw
297 }
298}
299
300fn shorten_path(path: &str) -> String {
301 if path.len() <= 40 {
302 return path.to_string();
303 }
304 let parts: Vec<&str> = path.split('/').collect();
306 if parts.len() > 2 {
307 format!("…/{}", parts[parts.len() - 2..].join("/"))
308 } else {
309 path.to_string()
310 }
311}
312
313fn truncate(s: &str, max: usize) -> String {
314 if s.chars().count() <= max {
315 s.to_string()
316 } else {
317 let truncated: String = s.chars().take(max - 1).collect();
318 format!("{truncated}…")
319 }
320}
321
322pub fn extract_content_hint(output: &str) -> String {
325 let lines: Vec<&str> = output.lines().skip(1).take(20).collect();
326
327 for line in &lines {
329 let trimmed = line.trim();
330 if trimmed.starts_with("deps:")
331 || trimmed.starts_with("exports:")
332 || trimmed.starts_with("//!")
333 {
334 return trimmed[..trimmed.len().min(80)].to_string();
335 }
336 }
337
338 for line in &lines {
340 let trimmed = line.trim();
341 if trimmed.starts_with("pub struct ")
342 || trimmed.starts_with("pub fn ")
343 || trimmed.starts_with("pub enum ")
344 || trimmed.starts_with("pub trait ")
345 || trimmed.starts_with("impl ")
346 || trimmed.starts_with("class ")
347 || trimmed.starts_with("export ")
348 || trimmed.starts_with("export default ")
349 || trimmed.starts_with("export function ")
350 || trimmed.starts_with("def ")
351 || trimmed.starts_with("func ")
352 {
353 return trimmed[..trimmed.len().min(70)].to_string();
354 }
355 }
356
357 for line in &lines {
359 let trimmed = line.trim();
360 if trimmed.starts_with("///") || trimmed.starts_with("# ") {
361 return trimmed[..trimmed.len().min(70)].to_string();
362 }
363 }
364
365 String::new()
366}
367
368fn extract_search_pattern(lines: &[&str]) -> String {
369 for line in lines.iter().take(3) {
371 if let Some(p) = line
372 .strip_prefix("pattern:")
373 .or_else(|| line.strip_prefix("Pattern:"))
374 .or_else(|| line.strip_prefix("query:"))
375 {
376 return p.trim().trim_matches('"').to_string();
377 }
378 }
379
380 for line in lines.iter().rev().take(3) {
382 if let Some(start) = line.find('`') {
383 if let Some(end) = line[start + 1..].find('`') {
384 return line[start + 1..start + 1 + end].to_string();
385 }
386 }
387 if let Some(start) = line.find("for \"") {
388 if let Some(end) = line[start + 5..].find('"') {
389 return line[start + 5..start + 5 + end].to_string();
390 }
391 }
392 }
393
394 "?".to_string()
395}
396
397fn extract_test_result(lines: &[&str], cmd: &str) -> Option<String> {
398 let is_test_cmd = cmd.contains("test")
399 || cmd.contains("pytest")
400 || cmd.contains("jest")
401 || cmd.contains("vitest")
402 || cmd.contains("mocha");
403
404 if !is_test_cmd {
405 return None;
406 }
407
408 for line in lines.iter().rev().take(10) {
410 if line.contains("test result:") {
412 let short_cmd = &cmd[..cmd.len().min(30)];
413 let result = line.trim();
414 return Some(truncate(
415 &format!("Test `{short_cmd}`: {result}"),
416 MAX_SUMMARY_LEN,
417 ));
418 }
419 if (line.contains(" passed") || line.contains(" failed"))
421 && (line.contains("pytest") || line.contains("==="))
422 {
423 let short_cmd = &cmd[..cmd.len().min(30)];
424 let result = line.trim().trim_matches('=').trim();
425 return Some(truncate(
426 &format!("Test `{short_cmd}`: {result}"),
427 MAX_SUMMARY_LEN,
428 ));
429 }
430 }
431
432 None
433}
434
435fn extract_build_result(lines: &[&str], cmd: &str) -> Option<String> {
436 let is_build = cmd.contains("build")
437 || cmd.contains("clippy")
438 || cmd.contains("check")
439 || cmd.contains("compile");
440
441 if !is_build {
442 return None;
443 }
444
445 for line in lines.iter().rev().take(5) {
447 if line.contains("Finished") {
448 let short_cmd = &cmd[..cmd.len().min(30)];
449 let errors = lines.iter().filter(|l| l.contains("error[")).count();
451 let warnings = lines
452 .iter()
453 .filter(|l| l.contains("warning:") && !l.contains("generated"))
454 .count();
455
456 return if errors > 0 {
457 Some(truncate(
458 &format!("Build `{short_cmd}`: {errors} errors, {warnings} warnings"),
459 MAX_SUMMARY_LEN,
460 ))
461 } else if warnings > 0 {
462 Some(truncate(
463 &format!("Build `{short_cmd}`: OK, {warnings} warnings"),
464 MAX_SUMMARY_LEN,
465 ))
466 } else {
467 Some(format!("Build `{short_cmd}`: OK"))
468 };
469 }
470 }
471
472 None
473}
474
475#[cfg(test)]
476pub(crate) fn clear_recent() {
477 if let Ok(mut recent) = RECENT.lock() {
478 recent.clear();
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use serial_test::serial;
486
487 #[test]
488 fn ctx_read_extracts_path_and_content() {
489 let output = "src/server/mod.rs 1400L\n deps: tokio, serde\n\npub struct Server {";
490 let f = extract_ctx_read(output).unwrap();
491 assert_eq!(f.file.as_deref(), Some("src/server/mod.rs"));
492 assert!(f.summary.contains("1400L"));
493 assert!(
494 f.summary.contains("deps: tokio, serde"),
495 "deps line should be preferred over struct: {}",
496 f.summary
497 );
498 }
499
500 #[test]
501 fn ctx_read_with_bracket_info() {
502 let output = "src/lib.rs [45L, full mode, 320 tok]\npub fn main() {}";
503 let f = extract_ctx_read(output).unwrap();
504 assert_eq!(f.file.as_deref(), Some("src/lib.rs"));
505 assert!(f.summary.contains("pub fn main"));
506 }
507
508 #[test]
509 fn ctx_read_ignores_errors() {
510 assert!(extract_ctx_read("ERROR: file not found").is_none());
511 assert!(extract_ctx_read("").is_none());
512 }
513
514 #[test]
515 fn ctx_search_shows_files() {
516 let output = "pattern: \"pub fn extract\"\nsrc/auto_findings.rs:19: pub fn extract\nsrc/core/mod.rs:5: pub fn extract_data\n[2 matches in 2 files]";
517 let f = extract_ctx_search(output).unwrap();
518 assert!(f.summary.contains("pub fn extract"));
519 assert!(f.summary.contains("auto_findings.rs"));
520 assert!(f.summary.contains("2 files"));
521 }
522
523 #[test]
524 fn ctx_search_ignores_no_matches() {
525 let output = "0 matches found";
526 assert!(extract_ctx_search(output).is_none());
527 }
528
529 #[test]
530 fn ctx_shell_captures_test_results() {
531 let output = "exit: 0\n$ cargo test --lib\nrunning 2845 tests\ntest result: ok. 2845 passed; 0 failed; 1 ignored;";
532 let f = extract_ctx_shell(output).unwrap();
533 assert!(f.summary.contains("2845 passed"));
534 assert!(f.summary.contains("cargo test"));
535 }
536
537 #[test]
538 fn ctx_shell_captures_build_ok() {
539 let output = "exit: 0\n$ cargo build --release\n Compiling lean-ctx v3.6.17\n Finished `release` profile in 2m 15s";
540 let f = extract_ctx_shell(output).unwrap();
541 assert!(f.summary.contains("Build"));
542 assert!(f.summary.contains("OK"));
543 }
544
545 #[test]
546 fn ctx_shell_captures_failed_with_error() {
547 let output = "exit: 1\n$ cargo clippy\nerror[E0425]: cannot find value `x`";
548 let f = extract_ctx_shell(output).unwrap();
549 assert!(f.summary.contains("FAILED"));
550 assert!(f.summary.contains("clippy"));
551 assert!(f.summary.contains("E0425"));
552 }
553
554 #[test]
555 fn ctx_shell_ignores_plain_success() {
556 let output = "exit: 0\n$ echo hello\nhello";
557 assert!(extract_ctx_shell(output).is_none());
558 }
559
560 #[test]
561 fn ctx_graph_extracts_related() {
562 let output = "Files related to mod.rs (15):";
563 let f = extract_ctx_graph(output).unwrap();
564 assert!(f.summary.contains("related"));
565 }
566
567 #[test]
568 #[serial]
569 fn dedup_prevents_duplicate_within_window() {
570 clear_recent();
571 let f1 = extract("ctx_read", "src/dedup_test.rs 100L\npub fn test() {}");
572 assert!(f1.is_some());
573 let f2 = extract("ctx_read", "src/dedup_test.rs 100L\npub fn test() {}");
574 assert!(f2.is_none());
575 }
576
577 #[test]
578 #[serial]
579 fn different_files_not_deduped() {
580 clear_recent();
581 let f1 = extract("ctx_read", "src/unique_a.rs 50L\nstruct A;");
582 assert!(f1.is_some());
583 let f2 = extract("ctx_read", "src/unique_b.rs 50L\nstruct B;");
584 assert!(f2.is_some());
585 }
586
587 #[test]
588 fn ctx_read_strips_cache_ref_prefix() {
589 let output = "F1=main.rs 10L\nfn main() {}";
590 let f = extract_ctx_read(output).unwrap();
591 assert_eq!(f.file.as_deref(), Some("main.rs"));
592 assert!(f.summary.starts_with("Read main.rs"));
593 }
594
595 #[test]
596 fn ctx_read_strips_multi_digit_ref() {
597 let output = "F12=src/lib.rs 120L\npub mod core;";
598 let f = extract_ctx_read(output).unwrap();
599 assert_eq!(f.file.as_deref(), Some("src/lib.rs"));
600 }
601
602 #[test]
603 fn unknown_tool_returns_none() {
604 assert!(extract("ctx_compile", "some output").is_none());
605 assert!(extract("ctx_overview", "overview data").is_none());
606 }
607
608 #[test]
609 fn truncation_works() {
610 let long = "a".repeat(200);
611 let result = truncate(&long, 120);
612 assert_eq!(result.chars().count(), 120);
613 assert!(result.ends_with('…'));
614 }
615
616 #[test]
617 fn session_watermark_filters_old_findings() {
618 use crate::core::session::SessionState;
619 use chrono::Utc;
620
621 let mut session = SessionState::new();
622 session.add_finding(Some("old.rs"), None, "old finding");
623
624 let watermark = Utc::now();
625 session.last_consolidate_ts = Some(watermark);
626
627 std::thread::sleep(std::time::Duration::from_millis(10));
628 session.add_finding(Some("new.rs"), None, "new finding");
629
630 let new_findings: Vec<_> = session
631 .findings
632 .iter()
633 .filter(|f| f.timestamp > watermark)
634 .collect();
635
636 assert_eq!(new_findings.len(), 1);
637 assert_eq!(new_findings[0].summary, "new finding");
638 }
639
640 #[test]
641 fn watermark_none_includes_all() {
642 use crate::core::session::SessionState;
643
644 let mut session = SessionState::new();
645 session.add_finding(Some("a.rs"), None, "first");
646 session.add_finding(Some("b.rs"), None, "second");
647
648 assert!(session.last_consolidate_ts.is_none());
649
650 let new_findings: Vec<_> = session
651 .findings
652 .iter()
653 .filter(|f| match session.last_consolidate_ts {
654 Some(ts) => f.timestamp > ts,
655 None => true,
656 })
657 .collect();
658
659 assert_eq!(new_findings.len(), 2);
660 }
661}