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