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/// Whether a finding only records what the agent itself did — a read, a search,
20/// a graph lookup, in exactly the wording [`extract`] produces. Such records serve
21/// the running session (briefing, recap, handoff); they are activity, not
22/// knowledge, so they never become durable facts. Failures, test and build
23/// results stay findings. Matching the producer's own formats also catches the
24/// records older sessions still carry.
25pub fn is_activity_record(summary: &str) -> bool {
26    if let Some(rest) = summary.strip_prefix("Read ") {
27        let head = rest.split_once(" — ").map_or(rest, |(head, _)| head);
28        let (path, tail) = head
29            .split_once(' ')
30            .map_or((head, None), |(p, t)| (p, Some(t)));
31        let line_count = |tail: &str| {
32            tail.strip_prefix('(')
33                .and_then(|t| t.strip_suffix("L)"))
34                .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
35        };
36        return looks_like_path(path) && tail.is_none_or(line_count);
37    }
38    (summary.starts_with("Found `") && summary.contains("` in ") && summary.contains(" files"))
39        || summary.starts_with("Semantic search `")
40        || summary.starts_with("Files related to ")
41}
42
43/// Extract a finding from a tool call result. Returns `None` if the output
44/// is not interesting or if a duplicate was emitted within the dedup window.
45///
46/// `path_hint` carries the `path` argument of the originating call (when the
47/// caller knows it): small full-mode reads emit raw content without a header
48/// line, so the path cannot be recovered from the output alone.
49pub fn extract(tool_name: &str, output: &str, path_hint: Option<&str>) -> Option<AutoFinding> {
50    let finding = match tool_name {
51        "ctx_read" => extract_ctx_read(output, path_hint),
52        "ctx_search" => extract_ctx_search(output),
53        "ctx_shell" => extract_ctx_shell(output),
54        "ctx_graph" => extract_ctx_graph(output),
55        "ctx_semantic_search" => extract_ctx_semantic_search(output),
56        _ => None,
57    }?;
58
59    let dedup_key = format!(
60        "{}:{}",
61        finding.file.as_deref().unwrap_or(""),
62        &finding.summary[..finding.summary.floor_char_boundary(80)]
63    );
64
65    if let Ok(mut recent) = RECENT.lock() {
66        let now = Instant::now();
67        recent.retain(|e| now.duration_since(e.at).as_secs() < DEDUP_WINDOW_SECS);
68
69        if recent.iter().any(|e| e.key == dedup_key) {
70            return None;
71        }
72        recent.push(RecentEntry {
73            key: dedup_key,
74            at: now,
75        });
76    }
77
78    Some(finding)
79}
80
81fn extract_ctx_read(output: &str, path_hint: Option<&str>) -> Option<AutoFinding> {
82    let first_line = output.lines().next().unwrap_or("");
83    if first_line.is_empty() || output.len() < 20 {
84        return None;
85    }
86
87    let raw_path = first_line
88        .split_whitespace()
89        .next()
90        .unwrap_or("")
91        .trim_end_matches([':', ']']);
92
93    let header_path = strip_cache_ref(raw_path);
94
95    // Prefer the header path, but only when it plausibly IS a path: small
96    // full-mode reads emit raw content without a header, and decorated blocks
97    // start with separators — "fn", "#", "---" must not become a junk
98    // "Read ---" finding that pollutes the session and every wakeup briefing
99    // (#658). In those cases fall back to the caller-supplied path argument.
100    let headerless =
101        header_path.is_empty() || header_path.starts_with('[') || !looks_like_path(header_path);
102    let path = if headerless {
103        path_hint?.trim()
104    } else {
105        header_path
106    };
107
108    if path.is_empty() || path.starts_with('[') || path.starts_with("ERROR") {
109        return None;
110    }
111    if is_noise_path(path) {
112        return None;
113    }
114
115    // Extract line count from output
116    let line_count = first_line
117        .split_whitespace()
118        .find(|w| w.ends_with('L') && w[..w.len() - 1].parse::<usize>().is_ok())
119        .unwrap_or("");
120
121    // Extract a content hint from the first few meaningful lines. The helper
122    // skips line 1 (normally the path header); for headerless raw content
123    // line 1 IS content, so pad with a synthetic header to keep it in scope.
124    let content_hint = if headerless {
125        extract_content_hint(&format!("\n{output}"))
126    } else {
127        extract_content_hint(output)
128    };
129
130    let short_path = shorten_path(path);
131    let summary = match (line_count.is_empty(), content_hint.is_empty()) {
132        (true, true) => format!("Read {short_path}"),
133        (false, true) => format!("Read {short_path} ({line_count})"),
134        (true, false) => truncate(
135            &format!("Read {short_path} — {content_hint}"),
136            MAX_SUMMARY_LEN,
137        ),
138        (false, false) => truncate(
139            &format!("Read {short_path} ({line_count}) — {content_hint}"),
140            MAX_SUMMARY_LEN,
141        ),
142    };
143
144    Some(AutoFinding {
145        file: Some(path.to_string()),
146        summary,
147    })
148}
149
150/// Heuristic: does this first-line token plausibly name a file (as opposed to
151/// raw file content like `fn`, `#`, `---`, `import`)? A path token contains a
152/// separator or an extension dot, and never consists solely of punctuation.
153fn looks_like_path(token: &str) -> bool {
154    if !token.contains('/') && !token.contains('\\') && !token.contains('.') {
155        return false;
156    }
157    token.chars().any(char::is_alphanumeric)
158}
159
160fn extract_ctx_search(output: &str) -> Option<AutoFinding> {
161    let lines: Vec<&str> = output.lines().collect();
162    if lines.is_empty() {
163        return None;
164    }
165
166    let last = lines.last().unwrap_or(&"");
167    if last.contains("0 matches") || last.contains("No matches") {
168        return None;
169    }
170
171    // Extract pattern from common output formats
172    let pattern = extract_search_pattern(&lines);
173
174    // Low-signal guard: if we could not identify a meaningful search pattern
175    // (placeholder "?") or it is a single trivial character, the resulting
176    // "Found `?` in N files" finding is pure noise — skip it.
177    if pattern == "?" || pattern.trim().chars().count() < 2 {
178        return None;
179    }
180
181    // Extract matched file names (lines with ':' that look like file:line matches),
182    // excluding noise paths (VCS/deps/build/home dotfiles).
183    let matched_files: Vec<&str> = lines
184        .iter()
185        .filter(|l| {
186            l.contains(':')
187                && !l.starts_with('[')
188                && !l.starts_with("pattern")
189                && !l.starts_with("Pattern")
190        })
191        .filter_map(|l| l.split(':').next())
192        .filter(|p| !is_noise_path(p))
193        .collect();
194
195    // Deduplicate file paths
196    let mut unique_files: Vec<&str> = Vec::new();
197    for f in &matched_files {
198        if !unique_files.contains(f) {
199            unique_files.push(f);
200        }
201    }
202
203    let match_count = matched_files.len();
204    let file_count = unique_files.len();
205
206    if match_count == 0 && file_count == 0 {
207        return None;
208    }
209
210    // Build summary with actual file names (top 3)
211    let file_list: String = if unique_files.len() <= 3 {
212        unique_files
213            .iter()
214            .map(|f| shorten_path(f))
215            .collect::<Vec<_>>()
216            .join(", ")
217    } else {
218        let top3: Vec<String> = unique_files[..3].iter().map(|f| shorten_path(f)).collect();
219        format!("{} +{} more", top3.join(", "), unique_files.len() - 3)
220    };
221
222    let summary = truncate(
223        &format!("Found `{pattern}` in {file_count} files: {file_list}"),
224        MAX_SUMMARY_LEN,
225    );
226
227    Some(AutoFinding {
228        file: None,
229        summary,
230    })
231}
232
233fn extract_ctx_shell(output: &str) -> Option<AutoFinding> {
234    let lines: Vec<&str> = output.lines().collect();
235    let first_line = lines.first().unwrap_or(&"");
236
237    // Extract command name
238    let cmd = lines
239        .iter()
240        .find(|l| l.starts_with("$ ") || l.starts_with("cmd:"))
241        .map_or("", |l| {
242            l.trim_start_matches("$ ").trim_start_matches("cmd:").trim()
243        });
244
245    // Check for test results (cargo test, pytest, jest, etc.)
246    if let Some(test_summary) = extract_test_result(&lines, cmd) {
247        return Some(AutoFinding {
248            file: None,
249            summary: test_summary,
250        });
251    }
252
253    // Check for build results (cargo build/clippy)
254    if let Some(build_summary) = extract_build_result(&lines, cmd) {
255        return Some(AutoFinding {
256            file: None,
257            summary: build_summary,
258        });
259    }
260
261    // Failed commands
262    if let Some(rest) = first_line.strip_prefix("exit:") {
263        let code = rest.split_whitespace().next().unwrap_or("?");
264        if code != "0" {
265            let short_cmd = &cmd[..cmd.floor_char_boundary(50)];
266            let error_hint = lines
267                .iter()
268                .find(|l| l.contains("error") || l.contains("Error") || l.contains("FAILED"))
269                .map_or("", |l| l.trim());
270            let error_short = &error_hint[..error_hint.floor_char_boundary(50)];
271
272            let summary = if error_short.is_empty() {
273                format!("FAILED (exit {code}): {short_cmd}")
274            } else {
275                truncate(
276                    &format!("FAILED (exit {code}): {short_cmd} — {error_short}"),
277                    MAX_SUMMARY_LEN,
278                )
279            };
280            return Some(AutoFinding {
281                file: None,
282                summary,
283            });
284        }
285    }
286
287    None
288}
289
290fn extract_ctx_graph(output: &str) -> Option<AutoFinding> {
291    let first_line = output.lines().next().unwrap_or("");
292
293    if first_line.starts_with("Files related to") || first_line.starts_with("No files depend") {
294        let file = first_line
295            .split_whitespace()
296            .last()
297            .unwrap_or("")
298            .trim_end_matches(':')
299            .trim_end_matches(|c: char| c == '(' || c.is_ascii_digit() || c == ')')
300            .to_string();
301
302        let count = first_line
303            .split('(')
304            .nth(1)
305            .and_then(|s| s.split(')').next())
306            .and_then(|s| s.parse::<usize>().ok())
307            .unwrap_or(0);
308
309        if count > 0 {
310            return Some(AutoFinding {
311                file: Some(file),
312                summary: first_line.to_string(),
313            });
314        }
315    }
316
317    None
318}
319
320fn extract_ctx_semantic_search(output: &str) -> Option<AutoFinding> {
321    let lines: Vec<&str> = output.lines().collect();
322    if lines.is_empty() {
323        return None;
324    }
325
326    // Count result entries (lines starting with a score or file path)
327    let results: Vec<&&str> = lines
328        .iter()
329        .filter(|l| l.starts_with("  ") || l.contains("score:") || l.contains("→"))
330        .collect();
331
332    if results.is_empty() {
333        return None;
334    }
335
336    // Try to get query from first line
337    let query = lines
338        .first()
339        .and_then(|l| {
340            l.strip_prefix("query:")
341                .or_else(|| l.strip_prefix("Query:"))
342        })
343        .map_or("semantic search", str::trim);
344
345    let summary = truncate(
346        &format!("Semantic search `{}` — {} results", query, results.len()),
347        MAX_SUMMARY_LEN,
348    );
349
350    Some(AutoFinding {
351        file: None,
352        summary,
353    })
354}
355
356// --- Helpers ---
357
358/// Path segments treated as noise in auto-findings and repo-wide walks (#1480).
359pub(crate) const NOISE_PATH_SEGMENTS: &[&str] = &[
360    ".git",
361    "node_modules",
362    ".ssh",
363    ".gnupg",
364    ".aws",
365    ".cargo",
366    ".rustup",
367    "target",
368    ".venv",
369    "venv",
370    "__pycache__",
371    "site-packages",
372    "dist-packages",
373    ".next",
374    ".cache",
375    "dist",
376    "build",
377    "vendor",
378    ".terraform",
379    ".worktrees",
380    "Library",
381    "ShipIt",
382    "SolutionPackages",
383    "agent-transcripts",
384    "terminals",
385    ".codex-worktrees",
386    ".cursor",
387    ".claude",
388    "worktrees",
389];
390
391/// Returns true for paths whose findings are noise rather than signal:
392/// VCS/dependency/build dirs, virtualenvs, caches, the user's home dotfiles
393/// (e.g. `~/.ssh/config`), and binary/log files. Such findings polluted the
394/// session and knowledge store (see EPIC 6 / #2363).
395pub fn is_noise_path(path: &str) -> bool {
396    let p = path.replace('\\', "/");
397    // Match a noise directory anywhere in the path (leading, middle, or with a
398    // leading slash). Splitting on components handles relative paths too.
399    if p.split('/').any(|c| NOISE_PATH_SEGMENTS.contains(&c)) {
400        return true;
401    }
402    // Home dotfiles outside any workspace (e.g. ~/.ssh/config, ~/.zshrc).
403    if let Some(home) = dirs::home_dir() {
404        let home_s = home.to_string_lossy().replace('\\', "/");
405        if let Some(rest) = p.strip_prefix(&home_s) {
406            let rest = rest.trim_start_matches('/');
407            if rest.starts_with('.') {
408                return true;
409            }
410        }
411    }
412    const NOISE_EXTS: &[&str] = &[
413        ".lock", ".log", ".min.js", ".map", ".png", ".jpg", ".jpeg", ".gif", ".pdf", ".zip",
414        ".tar", ".gz", ".bin", ".so", ".dylib", ".dll", ".o", ".a", ".class", ".wasm",
415    ];
416    let lower = p.to_ascii_lowercase();
417    NOISE_EXTS.iter().any(|ext| lower.ends_with(ext))
418}
419
420fn strip_cache_ref(raw: &str) -> &str {
421    if raw.len() > 3
422        && raw.starts_with('F')
423        && raw[1..].starts_with(|c: char| c.is_ascii_digit())
424        && raw.contains('=')
425    {
426        raw.split_once('=').map_or(raw, |(_, p)| p)
427    } else {
428        raw
429    }
430}
431
432fn shorten_path(path: &str) -> String {
433    if path.len() <= 40 {
434        return path.to_string();
435    }
436    // Keep last 2 segments
437    let parts: Vec<&str> = path.split('/').collect();
438    if parts.len() > 2 {
439        format!("…/{}", parts[parts.len() - 2..].join("/"))
440    } else {
441        path.to_string()
442    }
443}
444
445fn truncate(s: &str, max: usize) -> String {
446    if s.chars().count() <= max {
447        s.to_string()
448    } else {
449        let truncated: String = s.chars().take(max - 1).collect();
450        format!("{truncated}…")
451    }
452}
453
454/// Extracts a one-line structural hint from file/tool output.
455/// Shared between auto-findings and session file-summary generation.
456pub fn extract_content_hint(output: &str) -> String {
457    let lines: Vec<&str> = output.lines().skip(1).take(20).collect();
458
459    // Layer 1: deps/exports/module-level descriptions
460    for line in &lines {
461        let trimmed = line.trim();
462        if trimmed.starts_with("deps:")
463            || trimmed.starts_with("exports:")
464            || trimmed.starts_with("//!")
465        {
466            return trimmed[..trimmed.floor_char_boundary(80)].to_string();
467        }
468    }
469
470    // Layer 2: primary struct/fn/class/trait definitions
471    for line in &lines {
472        let trimmed = line.trim();
473        if trimmed.starts_with("pub struct ")
474            || trimmed.starts_with("pub fn ")
475            || trimmed.starts_with("pub enum ")
476            || trimmed.starts_with("pub trait ")
477            || trimmed.starts_with("impl ")
478            || trimmed.starts_with("class ")
479            || trimmed.starts_with("export ")
480            || trimmed.starts_with("export default ")
481            || trimmed.starts_with("export function ")
482            || trimmed.starts_with("def ")
483            || trimmed.starts_with("func ")
484        {
485            return trimmed[..trimmed.floor_char_boundary(70)].to_string();
486        }
487    }
488
489    // Layer 3: doc comments / markdown headings
490    for line in &lines {
491        let trimmed = line.trim();
492        if trimmed.starts_with("///") || trimmed.starts_with("# ") {
493            return trimmed[..trimmed.floor_char_boundary(70)].to_string();
494        }
495    }
496
497    String::new()
498}
499
500fn extract_search_pattern(lines: &[&str]) -> String {
501    // Try explicit pattern line
502    for line in lines.iter().take(3) {
503        if let Some(p) = line
504            .strip_prefix("pattern:")
505            .or_else(|| line.strip_prefix("Pattern:"))
506            .or_else(|| line.strip_prefix("query:"))
507        {
508            return p.trim().trim_matches('"').to_string();
509        }
510    }
511
512    // Try to infer from search summary line (e.g. "[4 matches for `foo` in 2 files]")
513    for line in lines.iter().rev().take(3) {
514        if let Some(start) = line.find('`')
515            && let Some(end) = line[start + 1..].find('`')
516        {
517            return line[start + 1..start + 1 + end].to_string();
518        }
519        if let Some(start) = line.find("for \"")
520            && let Some(end) = line[start + 5..].find('"')
521        {
522            return line[start + 5..start + 5 + end].to_string();
523        }
524    }
525
526    "?".to_string()
527}
528
529fn extract_test_result(lines: &[&str], cmd: &str) -> Option<String> {
530    let is_test_cmd = cmd.contains("test")
531        || cmd.contains("pytest")
532        || cmd.contains("jest")
533        || cmd.contains("vitest")
534        || cmd.contains("mocha");
535
536    if !is_test_cmd {
537        return None;
538    }
539
540    // Look for test result summary lines
541    for line in lines.iter().rev().take(10) {
542        // Rust: "test result: ok. 2845 passed; 0 failed;"
543        if line.contains("test result:") {
544            let short_cmd = &cmd[..cmd.floor_char_boundary(30)];
545            let result = line.trim();
546            return Some(truncate(
547                &format!("Test `{short_cmd}`: {result}"),
548                MAX_SUMMARY_LEN,
549            ));
550        }
551        // Python: "X passed, Y failed" or "X passed"
552        if (line.contains(" passed") || line.contains(" failed"))
553            && (line.contains("pytest") || line.contains("==="))
554        {
555            let short_cmd = &cmd[..cmd.floor_char_boundary(30)];
556            let result = line.trim().trim_matches('=').trim();
557            return Some(truncate(
558                &format!("Test `{short_cmd}`: {result}"),
559                MAX_SUMMARY_LEN,
560            ));
561        }
562    }
563
564    None
565}
566
567fn extract_build_result(lines: &[&str], cmd: &str) -> Option<String> {
568    let is_build = cmd.contains("build")
569        || cmd.contains("clippy")
570        || cmd.contains("check")
571        || cmd.contains("compile");
572
573    if !is_build {
574        return None;
575    }
576
577    // Look for Finished line (cargo)
578    for line in lines.iter().rev().take(5) {
579        if line.contains("Finished") {
580            let short_cmd = &cmd[..cmd.floor_char_boundary(30)];
581            // Count errors/warnings
582            let errors = lines.iter().filter(|l| l.contains("error[")).count();
583            let warnings = lines
584                .iter()
585                .filter(|l| l.contains("warning:") && !l.contains("generated"))
586                .count();
587
588            return if errors > 0 {
589                Some(truncate(
590                    &format!("Build `{short_cmd}`: {errors} errors, {warnings} warnings"),
591                    MAX_SUMMARY_LEN,
592                ))
593            } else if warnings > 0 {
594                Some(truncate(
595                    &format!("Build `{short_cmd}`: OK, {warnings} warnings"),
596                    MAX_SUMMARY_LEN,
597                ))
598            } else {
599                Some(format!("Build `{short_cmd}`: OK"))
600            };
601        }
602    }
603
604    None
605}
606
607#[cfg(test)]
608pub(crate) fn clear_recent() {
609    if let Ok(mut recent) = RECENT.lock() {
610        recent.clear();
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use serial_test::serial;
618
619    #[test]
620    fn activity_records_are_exactly_what_the_producer_writes() {
621        let activity = [
622            extract_ctx_read("src/server/mod.rs 1400L\n   deps: tokio, serde\n", None),
623            extract_ctx_read(
624                "src/lib.rs [45L, full mode, 320 tok]\npub fn main() {}",
625                None,
626            ),
627            extract_ctx_read(
628                "rust/src/core/very/deeply/nested/module/task_control.rs 438L\n\n",
629                None,
630            ),
631            extract_ctx_search(
632                "pattern: \"pub fn extract\"\nsrc/a.rs:19: pub fn extract\n[1 matches in 1 files]",
633            ),
634            extract_ctx_graph("Files related to mod.rs (15):"),
635        ];
636        for finding in activity {
637            let summary = finding.expect("producer output").summary;
638            assert!(is_activity_record(&summary), "activity: {summary}");
639        }
640        let knowledge = [
641            extract_ctx_shell("exit: 1\n$ cargo clippy\nerror[E0425]: cannot find value `x`"),
642            extract_ctx_shell(
643                "exit: 0\n$ cargo test --lib\nrunning 2 tests\ntest result: ok. 2 passed; 0 failed;",
644            ),
645        ];
646        for finding in knowledge {
647            let summary = finding.expect("producer output").summary;
648            assert!(!is_activity_record(&summary), "knowledge: {summary}");
649        }
650        for written in [
651            "Read the docs and found the retry bug",
652            "Read src/a.rs: the lock is taken twice",
653            "Blocker: cannot connect to the registry",
654        ] {
655            assert!(
656                !is_activity_record(written),
657                "a person's finding stays: {written}"
658            );
659        }
660    }
661
662    #[test]
663    fn ctx_read_extracts_path_and_content() {
664        let output = "src/server/mod.rs 1400L\n   deps: tokio, serde\n\npub struct Server {";
665        let f = extract_ctx_read(output, None).unwrap();
666        assert_eq!(f.file.as_deref(), Some("src/server/mod.rs"));
667        assert!(f.summary.contains("1400L"));
668        assert!(
669            f.summary.contains("deps: tokio, serde"),
670            "deps line should be preferred over struct: {}",
671            f.summary
672        );
673    }
674
675    #[test]
676    fn ctx_read_with_bracket_info() {
677        let output = "src/lib.rs [45L, full mode, 320 tok]\npub fn main() {}";
678        let f = extract_ctx_read(output, None).unwrap();
679        assert_eq!(f.file.as_deref(), Some("src/lib.rs"));
680        assert!(f.summary.contains("pub fn main"));
681    }
682
683    #[test]
684    fn ctx_read_ignores_errors() {
685        assert!(extract_ctx_read("ERROR: file not found", None).is_none());
686        assert!(extract_ctx_read("", None).is_none());
687    }
688
689    #[test]
690    fn ctx_search_shows_files() {
691        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]";
692        let f = extract_ctx_search(output).unwrap();
693        assert!(f.summary.contains("pub fn extract"));
694        assert!(f.summary.contains("auto_findings.rs"));
695        assert!(f.summary.contains("2 files"));
696    }
697
698    #[test]
699    fn ctx_search_ignores_no_matches() {
700        let output = "0 matches found";
701        assert!(extract_ctx_search(output).is_none());
702    }
703
704    #[test]
705    fn ctx_search_suppresses_unidentified_pattern() {
706        // No pattern/Pattern/query line and no backtick hint → pattern resolves
707        // to "?", which must not produce a "Found `?` in N files" noise finding.
708        let output = "src/a.rs:10: something\nsrc/b.rs:20: other\n[2 matches in 2 files]";
709        assert!(extract_ctx_search(output).is_none());
710    }
711
712    #[test]
713    fn ctx_search_skips_noise_paths_only() {
714        let output = "pattern: \"foo\"\nnode_modules/x/y.js:1: foo\n.git/config:2: foo\n[2 matches in 2 files]";
715        assert!(
716            extract_ctx_search(output).is_none(),
717            "matches only in node_modules/.git should yield no finding"
718        );
719    }
720
721    #[test]
722    fn ctx_read_skips_dependency_path() {
723        assert!(
724            extract_ctx_read(
725                "node_modules/react/index.js 50L\nexport default React;",
726                None
727            )
728            .is_none()
729        );
730        assert!(
731            extract_ctx_read("project/target/debug/build.rs 10L\nfn main() {}", None).is_none()
732        );
733    }
734
735    #[test]
736    fn noise_path_detects_home_dotfiles() {
737        let _home = crate::core::data_dir::test_env_lock();
738        if let Some(home) = dirs::home_dir() {
739            let ssh = format!("{}/.ssh/config", home.display());
740            assert!(is_noise_path(&ssh));
741        }
742        assert!(is_noise_path("a/node_modules/b.js"));
743        assert!(is_noise_path("pkg/foo.min.js"));
744        assert!(!is_noise_path("src/server/mod.rs"));
745    }
746
747    #[test]
748    fn ctx_shell_captures_test_results() {
749        let output = "exit: 0\n$ cargo test --lib\nrunning 2845 tests\ntest result: ok. 2845 passed; 0 failed; 1 ignored;";
750        let f = extract_ctx_shell(output).unwrap();
751        assert!(f.summary.contains("2845 passed"));
752        assert!(f.summary.contains("cargo test"));
753    }
754
755    #[test]
756    fn ctx_shell_captures_build_ok() {
757        let output = "exit: 0\n$ cargo build --release\n   Compiling lean-ctx v3.6.17\n    Finished `release` profile in 2m 15s";
758        let f = extract_ctx_shell(output).unwrap();
759        assert!(f.summary.contains("Build"));
760        assert!(f.summary.contains("OK"));
761    }
762
763    #[test]
764    fn ctx_shell_captures_failed_with_error() {
765        let output = "exit: 1\n$ cargo clippy\nerror[E0425]: cannot find value `x`";
766        let f = extract_ctx_shell(output).unwrap();
767        assert!(f.summary.contains("FAILED"));
768        assert!(f.summary.contains("clippy"));
769        assert!(f.summary.contains("E0425"));
770    }
771
772    #[test]
773    fn ctx_shell_ignores_plain_success() {
774        let output = "exit: 0\n$ echo hello\nhello";
775        assert!(extract_ctx_shell(output).is_none());
776    }
777
778    #[test]
779    fn ctx_graph_extracts_related() {
780        let output = "Files related to mod.rs (15):";
781        let f = extract_ctx_graph(output).unwrap();
782        assert!(f.summary.contains("related"));
783    }
784
785    #[test]
786    #[serial]
787    fn dedup_prevents_duplicate_within_window() {
788        clear_recent();
789        let f1 = extract("ctx_read", "src/dedup_test.rs 100L\npub fn test() {}", None);
790        assert!(f1.is_some());
791        let f2 = extract("ctx_read", "src/dedup_test.rs 100L\npub fn test() {}", None);
792        assert!(f2.is_none());
793    }
794
795    #[test]
796    #[serial]
797    fn different_files_not_deduped() {
798        clear_recent();
799        let f1 = extract("ctx_read", "src/unique_a.rs 50L\nstruct A;", None);
800        assert!(f1.is_some());
801        let f2 = extract("ctx_read", "src/unique_b.rs 50L\nstruct B;", None);
802        assert!(f2.is_some());
803    }
804
805    #[test]
806    fn ctx_read_rejects_decorated_output_without_hint() {
807        // Regression #658: a decorated block starting with "--- AUTO CONTEXT ---"
808        // (or raw content like "fn main() {" / "# Heading") must not turn its
809        // first token into a junk "Read ---" finding.
810        assert!(
811            extract_ctx_read(
812                "--- AUTO CONTEXT ---\nPROJECT OVERVIEW 1 files\n--- END ---",
813                None
814            )
815            .is_none()
816        );
817        assert!(extract_ctx_read("fn main() {\n    println!(\"hi\");\n}", None).is_none());
818        assert!(extract_ctx_read("# Fresh Demo\n\nA demo project for auditing.", None).is_none());
819    }
820
821    #[test]
822    fn ctx_read_uses_path_hint_for_headerless_output() {
823        // Small full-mode reads emit raw content without a path header; the
824        // caller-supplied `path` argument must fill the gap (#658).
825        let f = extract_ctx_read(
826            "# Fresh Demo\n\nA demo project used to audit the journey.",
827            Some("README.md"),
828        )
829        .unwrap();
830        assert_eq!(f.file.as_deref(), Some("README.md"));
831        assert!(
832            f.summary.starts_with("Read README.md"),
833            "got: {}",
834            f.summary
835        );
836        assert!(f.summary.contains("# Fresh Demo"), "got: {}", f.summary);
837    }
838
839    #[test]
840    fn ctx_read_hint_still_respects_noise_paths() {
841        assert!(
842            extract_ctx_read(
843                "content here that is long enough",
844                Some("node_modules/x.js")
845            )
846            .is_none()
847        );
848    }
849
850    #[test]
851    fn ctx_read_strips_cache_ref_prefix() {
852        let output = "F1=main.rs 10L\nfn main() {}";
853        let f = extract_ctx_read(output, None).unwrap();
854        assert_eq!(f.file.as_deref(), Some("main.rs"));
855        assert!(f.summary.starts_with("Read main.rs"));
856    }
857
858    #[test]
859    fn ctx_read_strips_multi_digit_ref() {
860        let output = "F12=src/lib.rs 120L\npub mod core;";
861        let f = extract_ctx_read(output, None).unwrap();
862        assert_eq!(f.file.as_deref(), Some("src/lib.rs"));
863    }
864
865    #[test]
866    fn unknown_tool_returns_none() {
867        assert!(extract("ctx_compile", "some output", None).is_none());
868        assert!(extract("ctx_overview", "overview data", None).is_none());
869    }
870
871    #[test]
872    fn truncation_works() {
873        let long = "a".repeat(200);
874        let result = truncate(&long, 120);
875        assert_eq!(result.chars().count(), 120);
876        assert!(result.ends_with('…'));
877    }
878
879    #[test]
880    fn session_watermark_filters_old_findings() {
881        use crate::core::session::SessionState;
882        use chrono::Utc;
883
884        let mut session = SessionState::new();
885        session.add_finding(Some("old.rs"), None, "old finding");
886
887        let watermark = Utc::now();
888        session.last_consolidate_ts = Some(watermark);
889
890        std::thread::sleep(std::time::Duration::from_millis(10));
891        session.add_finding(Some("new.rs"), None, "new finding");
892
893        let new_findings: Vec<_> = session
894            .findings
895            .iter()
896            .filter(|f| f.timestamp > watermark)
897            .collect();
898
899        assert_eq!(new_findings.len(), 1);
900        assert_eq!(new_findings[0].summary, "new finding");
901    }
902
903    #[test]
904    fn watermark_none_includes_all() {
905        use crate::core::session::SessionState;
906
907        let mut session = SessionState::new();
908        session.add_finding(Some("a.rs"), None, "first");
909        session.add_finding(Some("b.rs"), None, "second");
910
911        assert!(session.last_consolidate_ts.is_none());
912
913        let new_findings: Vec<_> = session
914            .findings
915            .iter()
916            .filter(|f| match session.last_consolidate_ts {
917                Some(ts) => f.timestamp > ts,
918                None => true,
919            })
920            .collect();
921
922        assert_eq!(new_findings.len(), 2);
923    }
924
925    #[test]
926    #[serial]
927    fn extract_content_hint_survives_multibyte_byte_budget() {
928        // Regression for #379: a matched line whose byte-budget cut (70/80) lands
929        // inside a 2-byte Cyrillic char must snap to a char boundary, not panic.
930        // `extract_content_hint` skips the first line, so each input has a header.
931        clear_recent();
932
933        // Layer 3 — markdown / doc heading (the reported auto_findings.rs:424,
934        // budget 70). "# _" is 3 bytes, so byte 70 lands mid-char in the run.
935        let line = format!("# _{}", "я".repeat(40));
936        let hint = extract_content_hint(&format!("header\n{line}"));
937        assert!(
938            line.starts_with(&hint),
939            "hint must be a valid prefix: {hint}"
940        );
941        assert!(hint.starts_with("# _") && (60..=70).contains(&hint.len()));
942
943        // Layer 2 — definition line (budget 70).
944        let def = format!("def _{}", "я".repeat(40));
945        let hint = extract_content_hint(&format!("header\n{def}"));
946        assert!(def.starts_with(&hint) && hint.starts_with("def _") && hint.len() <= 70);
947
948        // Layer 1 — module doc comment (budget 80).
949        let doc = format!("//!{}", "я".repeat(40));
950        let hint = extract_content_hint(&format!("header\n{doc}"));
951        assert!(doc.starts_with(&hint) && hint.starts_with("//!") && hint.len() <= 80);
952    }
953
954    #[test]
955    #[serial]
956    fn extract_shell_survives_multibyte_byte_budget() {
957        // Failed-command path slices cmd@50 and error_hint@50; both cross a 2-byte
958        // char boundary here (#379 class). Must return a finding, not panic.
959        clear_recent();
960        let output = format!("exit: 1\n$ x{}\nerror: {}", "я".repeat(60), "ж".repeat(60));
961        let f = extract("ctx_shell", &output, None).expect("failed-command finding");
962        assert!(f.summary.contains("FAILED"), "got: {}", f.summary);
963
964        // Test-result path slices cmd@30.
965        clear_recent();
966        let test_out = format!(
967            "$ cargo test {}\n   test result: ok. 5 passed; 0 failed;",
968            "я".repeat(40)
969        );
970        let t = extract("ctx_shell", &test_out, None).expect("test-result finding");
971        assert!(t.summary.contains("Test"), "got: {}", t.summary);
972    }
973
974    #[test]
975    #[serial]
976    fn extract_search_dedup_survives_multibyte_summary() {
977        // The dedup key slices finding.summary@80 (auto_findings.rs:34). A long
978        // Cyrillic search pattern yields a >80-byte multibyte summary; the cut
979        // must be char-boundary safe.
980        clear_recent();
981        let output = format!("pattern: \"{}\"\nsrc/main.rs:10: match", "я".repeat(50));
982        let f = extract("ctx_search", &output, None).expect("search finding");
983        assert!(f.summary.contains("Found"), "got: {}", f.summary);
984    }
985}