Skip to main content

lean_ctx/core/
auto_findings.rs

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