Skip to main content

lean_ctx/core/
auto_findings.rs

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
18/// Extract a finding from a tool call result. Returns `None` if the output
19/// is not interesting or if a duplicate was emitted within the dedup window.
20pub 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    // Extract line count from output
71    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    // Extract a content hint from first few meaningful lines
77    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    // Extract pattern from common output formats
111    let pattern = extract_search_pattern(&lines);
112
113    // Extract matched file names (lines with ':' that look like file:line matches)
114    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    // Deduplicate file paths
126    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    // Build summary with actual file names (top 3)
141    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    // Extract command name
168    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    // Check for test results (cargo test, pytest, jest, etc.)
176    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    // Check for build results (cargo build/clippy)
184    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    // Failed commands
192    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    // Count result entries (lines starting with a score or file path)
257    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    // Try to get query from first line
267    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
286// --- Helpers ---
287
288fn 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    // Keep last 2 segments
305    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
322/// Extracts a one-line structural hint from file/tool output.
323/// Shared between auto-findings and session file-summary generation.
324pub fn extract_content_hint(output: &str) -> String {
325    let lines: Vec<&str> = output.lines().skip(1).take(20).collect();
326
327    // Layer 1: deps/exports/module-level descriptions
328    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    // Layer 2: primary struct/fn/class/trait definitions
339    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    // Layer 3: doc comments / markdown headings
358    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    // Try explicit pattern line
370    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    // Try to infer from search summary line (e.g. "[4 matches for `foo` in 2 files]")
381    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    // Look for test result summary lines
409    for line in lines.iter().rev().take(10) {
410        // Rust: "test result: ok. 2845 passed; 0 failed;"
411        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        // Python: "X passed, Y failed" or "X passed"
420        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    // Look for Finished line (cargo)
446    for line in lines.iter().rev().take(5) {
447        if line.contains("Finished") {
448            let short_cmd = &cmd[..cmd.len().min(30)];
449            // Count errors/warnings
450            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}