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