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