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(crate) 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
43pub 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 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 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 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
150fn 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 let pattern = extract_search_pattern(&lines);
173
174 if pattern == "?" || pattern.trim().chars().count() < 2 {
178 return None;
179 }
180
181 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 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 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 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 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 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 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 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 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
356pub(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
391pub fn is_noise_path(path: &str) -> bool {
396 let p = path.replace('\\', "/");
397 if p.split('/').any(|c| NOISE_PATH_SEGMENTS.contains(&c)) {
400 return true;
401 }
402 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 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
454pub fn extract_content_hint(output: &str) -> String {
457 let lines: Vec<&str> = output.lines().skip(1).take(20).collect();
458
459 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 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 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 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 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 for line in lines.iter().rev().take(10) {
542 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 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 for line in lines.iter().rev().take(5) {
579 if line.contains("Finished") {
580 let short_cmd = &cmd[..cmd.floor_char_boundary(30)];
581 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 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 if let Some(home) = dirs::home_dir() {
738 let ssh = format!("{}/.ssh/config", home.display());
739 assert!(is_noise_path(&ssh));
740 }
741 assert!(is_noise_path("a/node_modules/b.js"));
742 assert!(is_noise_path("pkg/foo.min.js"));
743 assert!(!is_noise_path("src/server/mod.rs"));
744 }
745
746 #[test]
747 fn ctx_shell_captures_test_results() {
748 let output = "exit: 0\n$ cargo test --lib\nrunning 2845 tests\ntest result: ok. 2845 passed; 0 failed; 1 ignored;";
749 let f = extract_ctx_shell(output).unwrap();
750 assert!(f.summary.contains("2845 passed"));
751 assert!(f.summary.contains("cargo test"));
752 }
753
754 #[test]
755 fn ctx_shell_captures_build_ok() {
756 let output = "exit: 0\n$ cargo build --release\n Compiling lean-ctx v3.6.17\n Finished `release` profile in 2m 15s";
757 let f = extract_ctx_shell(output).unwrap();
758 assert!(f.summary.contains("Build"));
759 assert!(f.summary.contains("OK"));
760 }
761
762 #[test]
763 fn ctx_shell_captures_failed_with_error() {
764 let output = "exit: 1\n$ cargo clippy\nerror[E0425]: cannot find value `x`";
765 let f = extract_ctx_shell(output).unwrap();
766 assert!(f.summary.contains("FAILED"));
767 assert!(f.summary.contains("clippy"));
768 assert!(f.summary.contains("E0425"));
769 }
770
771 #[test]
772 fn ctx_shell_ignores_plain_success() {
773 let output = "exit: 0\n$ echo hello\nhello";
774 assert!(extract_ctx_shell(output).is_none());
775 }
776
777 #[test]
778 fn ctx_graph_extracts_related() {
779 let output = "Files related to mod.rs (15):";
780 let f = extract_ctx_graph(output).unwrap();
781 assert!(f.summary.contains("related"));
782 }
783
784 #[test]
785 #[serial]
786 fn dedup_prevents_duplicate_within_window() {
787 clear_recent();
788 let f1 = extract("ctx_read", "src/dedup_test.rs 100L\npub fn test() {}", None);
789 assert!(f1.is_some());
790 let f2 = extract("ctx_read", "src/dedup_test.rs 100L\npub fn test() {}", None);
791 assert!(f2.is_none());
792 }
793
794 #[test]
795 #[serial]
796 fn different_files_not_deduped() {
797 clear_recent();
798 let f1 = extract("ctx_read", "src/unique_a.rs 50L\nstruct A;", None);
799 assert!(f1.is_some());
800 let f2 = extract("ctx_read", "src/unique_b.rs 50L\nstruct B;", None);
801 assert!(f2.is_some());
802 }
803
804 #[test]
805 fn ctx_read_rejects_decorated_output_without_hint() {
806 assert!(
810 extract_ctx_read(
811 "--- AUTO CONTEXT ---\nPROJECT OVERVIEW 1 files\n--- END ---",
812 None
813 )
814 .is_none()
815 );
816 assert!(extract_ctx_read("fn main() {\n println!(\"hi\");\n}", None).is_none());
817 assert!(extract_ctx_read("# Fresh Demo\n\nA demo project for auditing.", None).is_none());
818 }
819
820 #[test]
821 fn ctx_read_uses_path_hint_for_headerless_output() {
822 let f = extract_ctx_read(
825 "# Fresh Demo\n\nA demo project used to audit the journey.",
826 Some("README.md"),
827 )
828 .unwrap();
829 assert_eq!(f.file.as_deref(), Some("README.md"));
830 assert!(
831 f.summary.starts_with("Read README.md"),
832 "got: {}",
833 f.summary
834 );
835 assert!(f.summary.contains("# Fresh Demo"), "got: {}", f.summary);
836 }
837
838 #[test]
839 fn ctx_read_hint_still_respects_noise_paths() {
840 assert!(
841 extract_ctx_read(
842 "content here that is long enough",
843 Some("node_modules/x.js")
844 )
845 .is_none()
846 );
847 }
848
849 #[test]
850 fn ctx_read_strips_cache_ref_prefix() {
851 let output = "F1=main.rs 10L\nfn main() {}";
852 let f = extract_ctx_read(output, None).unwrap();
853 assert_eq!(f.file.as_deref(), Some("main.rs"));
854 assert!(f.summary.starts_with("Read main.rs"));
855 }
856
857 #[test]
858 fn ctx_read_strips_multi_digit_ref() {
859 let output = "F12=src/lib.rs 120L\npub mod core;";
860 let f = extract_ctx_read(output, None).unwrap();
861 assert_eq!(f.file.as_deref(), Some("src/lib.rs"));
862 }
863
864 #[test]
865 fn unknown_tool_returns_none() {
866 assert!(extract("ctx_compile", "some output", None).is_none());
867 assert!(extract("ctx_overview", "overview data", None).is_none());
868 }
869
870 #[test]
871 fn truncation_works() {
872 let long = "a".repeat(200);
873 let result = truncate(&long, 120);
874 assert_eq!(result.chars().count(), 120);
875 assert!(result.ends_with('…'));
876 }
877
878 #[test]
879 fn session_watermark_filters_old_findings() {
880 use crate::core::session::SessionState;
881 use chrono::Utc;
882
883 let mut session = SessionState::new();
884 session.add_finding(Some("old.rs"), None, "old finding");
885
886 let watermark = Utc::now();
887 session.last_consolidate_ts = Some(watermark);
888
889 std::thread::sleep(std::time::Duration::from_millis(10));
890 session.add_finding(Some("new.rs"), None, "new finding");
891
892 let new_findings: Vec<_> = session
893 .findings
894 .iter()
895 .filter(|f| f.timestamp > watermark)
896 .collect();
897
898 assert_eq!(new_findings.len(), 1);
899 assert_eq!(new_findings[0].summary, "new finding");
900 }
901
902 #[test]
903 fn watermark_none_includes_all() {
904 use crate::core::session::SessionState;
905
906 let mut session = SessionState::new();
907 session.add_finding(Some("a.rs"), None, "first");
908 session.add_finding(Some("b.rs"), None, "second");
909
910 assert!(session.last_consolidate_ts.is_none());
911
912 let new_findings: Vec<_> = session
913 .findings
914 .iter()
915 .filter(|f| match session.last_consolidate_ts {
916 Some(ts) => f.timestamp > ts,
917 None => true,
918 })
919 .collect();
920
921 assert_eq!(new_findings.len(), 2);
922 }
923
924 #[test]
925 #[serial]
926 fn extract_content_hint_survives_multibyte_byte_budget() {
927 clear_recent();
931
932 let line = format!("# _{}", "я".repeat(40));
935 let hint = extract_content_hint(&format!("header\n{line}"));
936 assert!(
937 line.starts_with(&hint),
938 "hint must be a valid prefix: {hint}"
939 );
940 assert!(hint.starts_with("# _") && (60..=70).contains(&hint.len()));
941
942 let def = format!("def _{}", "я".repeat(40));
944 let hint = extract_content_hint(&format!("header\n{def}"));
945 assert!(def.starts_with(&hint) && hint.starts_with("def _") && hint.len() <= 70);
946
947 let doc = format!("//!{}", "я".repeat(40));
949 let hint = extract_content_hint(&format!("header\n{doc}"));
950 assert!(doc.starts_with(&hint) && hint.starts_with("//!") && hint.len() <= 80);
951 }
952
953 #[test]
954 #[serial]
955 fn extract_shell_survives_multibyte_byte_budget() {
956 clear_recent();
959 let output = format!("exit: 1\n$ x{}\nerror: {}", "я".repeat(60), "ж".repeat(60));
960 let f = extract("ctx_shell", &output, None).expect("failed-command finding");
961 assert!(f.summary.contains("FAILED"), "got: {}", f.summary);
962
963 clear_recent();
965 let test_out = format!(
966 "$ cargo test {}\n test result: ok. 5 passed; 0 failed;",
967 "я".repeat(40)
968 );
969 let t = extract("ctx_shell", &test_out, None).expect("test-result finding");
970 assert!(t.summary.contains("Test"), "got: {}", t.summary);
971 }
972
973 #[test]
974 #[serial]
975 fn extract_search_dedup_survives_multibyte_summary() {
976 clear_recent();
980 let output = format!("pattern: \"{}\"\nsrc/main.rs:10: match", "я".repeat(50));
981 let f = extract("ctx_search", &output, None).expect("search finding");
982 assert!(f.summary.contains("Found"), "got: {}", f.summary);
983 }
984}