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    if is_noise_path(path) {
71        return None;
72    }
73
74    // Extract line count from output
75    let line_count = first_line
76        .split_whitespace()
77        .find(|w| w.ends_with('L') && w[..w.len() - 1].parse::<usize>().is_ok())
78        .unwrap_or("");
79
80    // Extract a content hint from first few meaningful lines
81    let content_hint = extract_content_hint(output);
82
83    let short_path = shorten_path(path);
84    let summary = match (line_count.is_empty(), content_hint.is_empty()) {
85        (true, true) => format!("Read {short_path}"),
86        (false, true) => format!("Read {short_path} ({line_count})"),
87        (true, false) => truncate(
88            &format!("Read {short_path} — {content_hint}"),
89            MAX_SUMMARY_LEN,
90        ),
91        (false, false) => truncate(
92            &format!("Read {short_path} ({line_count}) — {content_hint}"),
93            MAX_SUMMARY_LEN,
94        ),
95    };
96
97    Some(AutoFinding {
98        file: Some(path.to_string()),
99        summary,
100    })
101}
102
103fn extract_ctx_search(output: &str) -> Option<AutoFinding> {
104    let lines: Vec<&str> = output.lines().collect();
105    if lines.is_empty() {
106        return None;
107    }
108
109    let last = lines.last().unwrap_or(&"");
110    if last.contains("0 matches") || last.contains("No matches") {
111        return None;
112    }
113
114    // Extract pattern from common output formats
115    let pattern = extract_search_pattern(&lines);
116
117    // Low-signal guard: if we could not identify a meaningful search pattern
118    // (placeholder "?") or it is a single trivial character, the resulting
119    // "Found `?` in N files" finding is pure noise — skip it.
120    if pattern == "?" || pattern.trim().chars().count() < 2 {
121        return None;
122    }
123
124    // Extract matched file names (lines with ':' that look like file:line matches),
125    // excluding noise paths (VCS/deps/build/home dotfiles).
126    let matched_files: Vec<&str> = lines
127        .iter()
128        .filter(|l| {
129            l.contains(':')
130                && !l.starts_with('[')
131                && !l.starts_with("pattern")
132                && !l.starts_with("Pattern")
133        })
134        .filter_map(|l| l.split(':').next())
135        .filter(|p| !is_noise_path(p))
136        .collect();
137
138    // Deduplicate file paths
139    let mut unique_files: Vec<&str> = Vec::new();
140    for f in &matched_files {
141        if !unique_files.contains(f) {
142            unique_files.push(f);
143        }
144    }
145
146    let match_count = matched_files.len();
147    let file_count = unique_files.len();
148
149    if match_count == 0 && file_count == 0 {
150        return None;
151    }
152
153    // Build summary with actual file names (top 3)
154    let file_list: String = if unique_files.len() <= 3 {
155        unique_files
156            .iter()
157            .map(|f| shorten_path(f))
158            .collect::<Vec<_>>()
159            .join(", ")
160    } else {
161        let top3: Vec<String> = unique_files[..3].iter().map(|f| shorten_path(f)).collect();
162        format!("{} +{} more", top3.join(", "), unique_files.len() - 3)
163    };
164
165    let summary = truncate(
166        &format!("Found `{pattern}` in {file_count} files: {file_list}"),
167        MAX_SUMMARY_LEN,
168    );
169
170    Some(AutoFinding {
171        file: None,
172        summary,
173    })
174}
175
176fn extract_ctx_shell(output: &str) -> Option<AutoFinding> {
177    let lines: Vec<&str> = output.lines().collect();
178    let first_line = lines.first().unwrap_or(&"");
179
180    // Extract command name
181    let cmd = lines
182        .iter()
183        .find(|l| l.starts_with("$ ") || l.starts_with("cmd:"))
184        .map_or("", |l| {
185            l.trim_start_matches("$ ").trim_start_matches("cmd:").trim()
186        });
187
188    // Check for test results (cargo test, pytest, jest, etc.)
189    if let Some(test_summary) = extract_test_result(&lines, cmd) {
190        return Some(AutoFinding {
191            file: None,
192            summary: test_summary,
193        });
194    }
195
196    // Check for build results (cargo build/clippy)
197    if let Some(build_summary) = extract_build_result(&lines, cmd) {
198        return Some(AutoFinding {
199            file: None,
200            summary: build_summary,
201        });
202    }
203
204    // Failed commands
205    if let Some(rest) = first_line.strip_prefix("exit:") {
206        let code = rest.split_whitespace().next().unwrap_or("?");
207        if code != "0" {
208            let short_cmd = &cmd[..cmd.len().min(50)];
209            let error_hint = lines
210                .iter()
211                .find(|l| l.contains("error") || l.contains("Error") || l.contains("FAILED"))
212                .map_or("", |l| l.trim());
213            let error_short = &error_hint[..error_hint.len().min(50)];
214
215            let summary = if error_short.is_empty() {
216                format!("FAILED (exit {code}): {short_cmd}")
217            } else {
218                truncate(
219                    &format!("FAILED (exit {code}): {short_cmd} — {error_short}"),
220                    MAX_SUMMARY_LEN,
221                )
222            };
223            return Some(AutoFinding {
224                file: None,
225                summary,
226            });
227        }
228    }
229
230    None
231}
232
233fn extract_ctx_graph(output: &str) -> Option<AutoFinding> {
234    let first_line = output.lines().next().unwrap_or("");
235
236    if first_line.starts_with("Files related to") || first_line.starts_with("No files depend") {
237        let file = first_line
238            .split_whitespace()
239            .last()
240            .unwrap_or("")
241            .trim_end_matches(':')
242            .trim_end_matches(|c: char| c == '(' || c.is_ascii_digit() || c == ')')
243            .to_string();
244
245        let count = first_line
246            .split('(')
247            .nth(1)
248            .and_then(|s| s.split(')').next())
249            .and_then(|s| s.parse::<usize>().ok())
250            .unwrap_or(0);
251
252        if count > 0 {
253            return Some(AutoFinding {
254                file: Some(file),
255                summary: first_line.to_string(),
256            });
257        }
258    }
259
260    None
261}
262
263fn extract_ctx_semantic_search(output: &str) -> Option<AutoFinding> {
264    let lines: Vec<&str> = output.lines().collect();
265    if lines.is_empty() {
266        return None;
267    }
268
269    // Count result entries (lines starting with a score or file path)
270    let results: Vec<&&str> = lines
271        .iter()
272        .filter(|l| l.starts_with("  ") || l.contains("score:") || l.contains("→"))
273        .collect();
274
275    if results.is_empty() {
276        return None;
277    }
278
279    // Try to get query from first line
280    let query = lines
281        .first()
282        .and_then(|l| {
283            l.strip_prefix("query:")
284                .or_else(|| l.strip_prefix("Query:"))
285        })
286        .map_or("semantic search", str::trim);
287
288    let summary = truncate(
289        &format!("Semantic search `{}` — {} results", query, results.len()),
290        MAX_SUMMARY_LEN,
291    );
292
293    Some(AutoFinding {
294        file: None,
295        summary,
296    })
297}
298
299// --- Helpers ---
300
301/// Returns true for paths whose findings are noise rather than signal:
302/// VCS/dependency/build dirs, virtualenvs, caches, the user's home dotfiles
303/// (e.g. `~/.ssh/config`), and binary/log files. Such findings polluted the
304/// session and knowledge store (see EPIC 6 / #2363).
305fn is_noise_path(path: &str) -> bool {
306    let p = path.replace('\\', "/");
307    const NOISE_SEGMENTS: &[&str] = &[
308        ".git",
309        "node_modules",
310        ".ssh",
311        ".gnupg",
312        ".aws",
313        ".cargo",
314        ".rustup",
315        "target",
316        ".venv",
317        "venv",
318        "__pycache__",
319        "site-packages",
320        "dist-packages",
321        ".next",
322        ".cache",
323        "dist",
324        "build",
325        "vendor",
326        ".terraform",
327    ];
328    // Match a noise directory anywhere in the path (leading, middle, or with a
329    // leading slash). Splitting on components handles relative paths too.
330    if p.split('/').any(|c| NOISE_SEGMENTS.contains(&c)) {
331        return true;
332    }
333    // Home dotfiles outside any workspace (e.g. ~/.ssh/config, ~/.zshrc).
334    if let Some(home) = dirs::home_dir() {
335        let home_s = home.to_string_lossy().replace('\\', "/");
336        if let Some(rest) = p.strip_prefix(&home_s) {
337            let rest = rest.trim_start_matches('/');
338            if rest.starts_with('.') {
339                return true;
340            }
341        }
342    }
343    const NOISE_EXTS: &[&str] = &[
344        ".lock", ".log", ".min.js", ".map", ".png", ".jpg", ".jpeg", ".gif", ".pdf", ".zip",
345        ".tar", ".gz", ".bin", ".so", ".dylib", ".dll", ".o", ".a", ".class", ".wasm",
346    ];
347    let lower = p.to_ascii_lowercase();
348    NOISE_EXTS.iter().any(|ext| lower.ends_with(ext))
349}
350
351fn strip_cache_ref(raw: &str) -> &str {
352    if raw.len() > 3
353        && raw.starts_with('F')
354        && raw[1..].starts_with(|c: char| c.is_ascii_digit())
355        && raw.contains('=')
356    {
357        raw.split_once('=').map_or(raw, |(_, p)| p)
358    } else {
359        raw
360    }
361}
362
363fn shorten_path(path: &str) -> String {
364    if path.len() <= 40 {
365        return path.to_string();
366    }
367    // Keep last 2 segments
368    let parts: Vec<&str> = path.split('/').collect();
369    if parts.len() > 2 {
370        format!("…/{}", parts[parts.len() - 2..].join("/"))
371    } else {
372        path.to_string()
373    }
374}
375
376fn truncate(s: &str, max: usize) -> String {
377    if s.chars().count() <= max {
378        s.to_string()
379    } else {
380        let truncated: String = s.chars().take(max - 1).collect();
381        format!("{truncated}…")
382    }
383}
384
385/// Extracts a one-line structural hint from file/tool output.
386/// Shared between auto-findings and session file-summary generation.
387pub fn extract_content_hint(output: &str) -> String {
388    let lines: Vec<&str> = output.lines().skip(1).take(20).collect();
389
390    // Layer 1: deps/exports/module-level descriptions
391    for line in &lines {
392        let trimmed = line.trim();
393        if trimmed.starts_with("deps:")
394            || trimmed.starts_with("exports:")
395            || trimmed.starts_with("//!")
396        {
397            return trimmed[..trimmed.len().min(80)].to_string();
398        }
399    }
400
401    // Layer 2: primary struct/fn/class/trait definitions
402    for line in &lines {
403        let trimmed = line.trim();
404        if trimmed.starts_with("pub struct ")
405            || trimmed.starts_with("pub fn ")
406            || trimmed.starts_with("pub enum ")
407            || trimmed.starts_with("pub trait ")
408            || trimmed.starts_with("impl ")
409            || trimmed.starts_with("class ")
410            || trimmed.starts_with("export ")
411            || trimmed.starts_with("export default ")
412            || trimmed.starts_with("export function ")
413            || trimmed.starts_with("def ")
414            || trimmed.starts_with("func ")
415        {
416            return trimmed[..trimmed.len().min(70)].to_string();
417        }
418    }
419
420    // Layer 3: doc comments / markdown headings
421    for line in &lines {
422        let trimmed = line.trim();
423        if trimmed.starts_with("///") || trimmed.starts_with("# ") {
424            return trimmed[..trimmed.len().min(70)].to_string();
425        }
426    }
427
428    String::new()
429}
430
431fn extract_search_pattern(lines: &[&str]) -> String {
432    // Try explicit pattern line
433    for line in lines.iter().take(3) {
434        if let Some(p) = line
435            .strip_prefix("pattern:")
436            .or_else(|| line.strip_prefix("Pattern:"))
437            .or_else(|| line.strip_prefix("query:"))
438        {
439            return p.trim().trim_matches('"').to_string();
440        }
441    }
442
443    // Try to infer from search summary line (e.g. "[4 matches for `foo` in 2 files]")
444    for line in lines.iter().rev().take(3) {
445        if let Some(start) = line.find('`') {
446            if let Some(end) = line[start + 1..].find('`') {
447                return line[start + 1..start + 1 + end].to_string();
448            }
449        }
450        if let Some(start) = line.find("for \"") {
451            if let Some(end) = line[start + 5..].find('"') {
452                return line[start + 5..start + 5 + end].to_string();
453            }
454        }
455    }
456
457    "?".to_string()
458}
459
460fn extract_test_result(lines: &[&str], cmd: &str) -> Option<String> {
461    let is_test_cmd = cmd.contains("test")
462        || cmd.contains("pytest")
463        || cmd.contains("jest")
464        || cmd.contains("vitest")
465        || cmd.contains("mocha");
466
467    if !is_test_cmd {
468        return None;
469    }
470
471    // Look for test result summary lines
472    for line in lines.iter().rev().take(10) {
473        // Rust: "test result: ok. 2845 passed; 0 failed;"
474        if line.contains("test result:") {
475            let short_cmd = &cmd[..cmd.len().min(30)];
476            let result = line.trim();
477            return Some(truncate(
478                &format!("Test `{short_cmd}`: {result}"),
479                MAX_SUMMARY_LEN,
480            ));
481        }
482        // Python: "X passed, Y failed" or "X passed"
483        if (line.contains(" passed") || line.contains(" failed"))
484            && (line.contains("pytest") || line.contains("==="))
485        {
486            let short_cmd = &cmd[..cmd.len().min(30)];
487            let result = line.trim().trim_matches('=').trim();
488            return Some(truncate(
489                &format!("Test `{short_cmd}`: {result}"),
490                MAX_SUMMARY_LEN,
491            ));
492        }
493    }
494
495    None
496}
497
498fn extract_build_result(lines: &[&str], cmd: &str) -> Option<String> {
499    let is_build = cmd.contains("build")
500        || cmd.contains("clippy")
501        || cmd.contains("check")
502        || cmd.contains("compile");
503
504    if !is_build {
505        return None;
506    }
507
508    // Look for Finished line (cargo)
509    for line in lines.iter().rev().take(5) {
510        if line.contains("Finished") {
511            let short_cmd = &cmd[..cmd.len().min(30)];
512            // Count errors/warnings
513            let errors = lines.iter().filter(|l| l.contains("error[")).count();
514            let warnings = lines
515                .iter()
516                .filter(|l| l.contains("warning:") && !l.contains("generated"))
517                .count();
518
519            return if errors > 0 {
520                Some(truncate(
521                    &format!("Build `{short_cmd}`: {errors} errors, {warnings} warnings"),
522                    MAX_SUMMARY_LEN,
523                ))
524            } else if warnings > 0 {
525                Some(truncate(
526                    &format!("Build `{short_cmd}`: OK, {warnings} warnings"),
527                    MAX_SUMMARY_LEN,
528                ))
529            } else {
530                Some(format!("Build `{short_cmd}`: OK"))
531            };
532        }
533    }
534
535    None
536}
537
538#[cfg(test)]
539pub(crate) fn clear_recent() {
540    if let Ok(mut recent) = RECENT.lock() {
541        recent.clear();
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use serial_test::serial;
549
550    #[test]
551    fn ctx_read_extracts_path_and_content() {
552        let output = "src/server/mod.rs 1400L\n   deps: tokio, serde\n\npub struct Server {";
553        let f = extract_ctx_read(output).unwrap();
554        assert_eq!(f.file.as_deref(), Some("src/server/mod.rs"));
555        assert!(f.summary.contains("1400L"));
556        assert!(
557            f.summary.contains("deps: tokio, serde"),
558            "deps line should be preferred over struct: {}",
559            f.summary
560        );
561    }
562
563    #[test]
564    fn ctx_read_with_bracket_info() {
565        let output = "src/lib.rs [45L, full mode, 320 tok]\npub fn main() {}";
566        let f = extract_ctx_read(output).unwrap();
567        assert_eq!(f.file.as_deref(), Some("src/lib.rs"));
568        assert!(f.summary.contains("pub fn main"));
569    }
570
571    #[test]
572    fn ctx_read_ignores_errors() {
573        assert!(extract_ctx_read("ERROR: file not found").is_none());
574        assert!(extract_ctx_read("").is_none());
575    }
576
577    #[test]
578    fn ctx_search_shows_files() {
579        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]";
580        let f = extract_ctx_search(output).unwrap();
581        assert!(f.summary.contains("pub fn extract"));
582        assert!(f.summary.contains("auto_findings.rs"));
583        assert!(f.summary.contains("2 files"));
584    }
585
586    #[test]
587    fn ctx_search_ignores_no_matches() {
588        let output = "0 matches found";
589        assert!(extract_ctx_search(output).is_none());
590    }
591
592    #[test]
593    fn ctx_search_suppresses_unidentified_pattern() {
594        // No pattern/Pattern/query line and no backtick hint → pattern resolves
595        // to "?", which must not produce a "Found `?` in N files" noise finding.
596        let output = "src/a.rs:10: something\nsrc/b.rs:20: other\n[2 matches in 2 files]";
597        assert!(extract_ctx_search(output).is_none());
598    }
599
600    #[test]
601    fn ctx_search_skips_noise_paths_only() {
602        let output = "pattern: \"foo\"\nnode_modules/x/y.js:1: foo\n.git/config:2: foo\n[2 matches in 2 files]";
603        assert!(
604            extract_ctx_search(output).is_none(),
605            "matches only in node_modules/.git should yield no finding"
606        );
607    }
608
609    #[test]
610    fn ctx_read_skips_dependency_path() {
611        assert!(
612            extract_ctx_read("node_modules/react/index.js 50L\nexport default React;").is_none()
613        );
614        assert!(extract_ctx_read("project/target/debug/build.rs 10L\nfn main() {}").is_none());
615    }
616
617    #[test]
618    fn noise_path_detects_home_dotfiles() {
619        if let Some(home) = dirs::home_dir() {
620            let ssh = format!("{}/.ssh/config", home.display());
621            assert!(is_noise_path(&ssh));
622        }
623        assert!(is_noise_path("a/node_modules/b.js"));
624        assert!(is_noise_path("pkg/foo.min.js"));
625        assert!(!is_noise_path("src/server/mod.rs"));
626    }
627
628    #[test]
629    fn ctx_shell_captures_test_results() {
630        let output = "exit: 0\n$ cargo test --lib\nrunning 2845 tests\ntest result: ok. 2845 passed; 0 failed; 1 ignored;";
631        let f = extract_ctx_shell(output).unwrap();
632        assert!(f.summary.contains("2845 passed"));
633        assert!(f.summary.contains("cargo test"));
634    }
635
636    #[test]
637    fn ctx_shell_captures_build_ok() {
638        let output = "exit: 0\n$ cargo build --release\n   Compiling lean-ctx v3.6.17\n    Finished `release` profile in 2m 15s";
639        let f = extract_ctx_shell(output).unwrap();
640        assert!(f.summary.contains("Build"));
641        assert!(f.summary.contains("OK"));
642    }
643
644    #[test]
645    fn ctx_shell_captures_failed_with_error() {
646        let output = "exit: 1\n$ cargo clippy\nerror[E0425]: cannot find value `x`";
647        let f = extract_ctx_shell(output).unwrap();
648        assert!(f.summary.contains("FAILED"));
649        assert!(f.summary.contains("clippy"));
650        assert!(f.summary.contains("E0425"));
651    }
652
653    #[test]
654    fn ctx_shell_ignores_plain_success() {
655        let output = "exit: 0\n$ echo hello\nhello";
656        assert!(extract_ctx_shell(output).is_none());
657    }
658
659    #[test]
660    fn ctx_graph_extracts_related() {
661        let output = "Files related to mod.rs (15):";
662        let f = extract_ctx_graph(output).unwrap();
663        assert!(f.summary.contains("related"));
664    }
665
666    #[test]
667    #[serial]
668    fn dedup_prevents_duplicate_within_window() {
669        clear_recent();
670        let f1 = extract("ctx_read", "src/dedup_test.rs 100L\npub fn test() {}");
671        assert!(f1.is_some());
672        let f2 = extract("ctx_read", "src/dedup_test.rs 100L\npub fn test() {}");
673        assert!(f2.is_none());
674    }
675
676    #[test]
677    #[serial]
678    fn different_files_not_deduped() {
679        clear_recent();
680        let f1 = extract("ctx_read", "src/unique_a.rs 50L\nstruct A;");
681        assert!(f1.is_some());
682        let f2 = extract("ctx_read", "src/unique_b.rs 50L\nstruct B;");
683        assert!(f2.is_some());
684    }
685
686    #[test]
687    fn ctx_read_strips_cache_ref_prefix() {
688        let output = "F1=main.rs 10L\nfn main() {}";
689        let f = extract_ctx_read(output).unwrap();
690        assert_eq!(f.file.as_deref(), Some("main.rs"));
691        assert!(f.summary.starts_with("Read main.rs"));
692    }
693
694    #[test]
695    fn ctx_read_strips_multi_digit_ref() {
696        let output = "F12=src/lib.rs 120L\npub mod core;";
697        let f = extract_ctx_read(output).unwrap();
698        assert_eq!(f.file.as_deref(), Some("src/lib.rs"));
699    }
700
701    #[test]
702    fn unknown_tool_returns_none() {
703        assert!(extract("ctx_compile", "some output").is_none());
704        assert!(extract("ctx_overview", "overview data").is_none());
705    }
706
707    #[test]
708    fn truncation_works() {
709        let long = "a".repeat(200);
710        let result = truncate(&long, 120);
711        assert_eq!(result.chars().count(), 120);
712        assert!(result.ends_with('…'));
713    }
714
715    #[test]
716    fn session_watermark_filters_old_findings() {
717        use crate::core::session::SessionState;
718        use chrono::Utc;
719
720        let mut session = SessionState::new();
721        session.add_finding(Some("old.rs"), None, "old finding");
722
723        let watermark = Utc::now();
724        session.last_consolidate_ts = Some(watermark);
725
726        std::thread::sleep(std::time::Duration::from_millis(10));
727        session.add_finding(Some("new.rs"), None, "new finding");
728
729        let new_findings: Vec<_> = session
730            .findings
731            .iter()
732            .filter(|f| f.timestamp > watermark)
733            .collect();
734
735        assert_eq!(new_findings.len(), 1);
736        assert_eq!(new_findings[0].summary, "new finding");
737    }
738
739    #[test]
740    fn watermark_none_includes_all() {
741        use crate::core::session::SessionState;
742
743        let mut session = SessionState::new();
744        session.add_finding(Some("a.rs"), None, "first");
745        session.add_finding(Some("b.rs"), None, "second");
746
747        assert!(session.last_consolidate_ts.is_none());
748
749        let new_findings: Vec<_> = session
750            .findings
751            .iter()
752            .filter(|f| match session.last_consolidate_ts {
753                Some(ts) => f.timestamp > ts,
754                None => true,
755            })
756            .collect();
757
758        assert_eq!(new_findings.len(), 2);
759    }
760}