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
19pub 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 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 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 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
126fn 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 let pattern = extract_search_pattern(&lines);
149
150 if pattern == "?" || pattern.trim().chars().count() < 2 {
154 return None;
155 }
156
157 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 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 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 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 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 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 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 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 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
332pub(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 if p.split('/').any(|c| NOISE_SEGMENTS.contains(&c)) {
364 return true;
365 }
366 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 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
418pub fn extract_content_hint(output: &str) -> String {
421 let lines: Vec<&str> = output.lines().skip(1).take(20).collect();
422
423 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 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 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 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 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 for line in lines.iter().rev().take(10) {
506 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 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 for line in lines.iter().rev().take(5) {
543 if line.contains("Finished") {
544 let short_cmd = &cmd[..cmd.floor_char_boundary(30)];
545 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 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 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 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 clear_recent();
852
853 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 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 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 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 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 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}