From 566999854a66907ef47a3834f8322a8b0dcf5341 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 18 Jun 2026 02:06:43 +0200 Subject: [PATCH] feat(pi-natives): returned total searched files count in grep results - Implemented file tracking during search using an `AtomicU64` counter. - Integrated the counter into parallel visitor structures to increment with each visited file. - Updated the streaming grep function signature to return the cumulative count of searched files. - Expanded the native test suite to verify accurate file counts and edge cases like empty search directories. - Added a JavaScript unit test to validate that advise tool calls are summarized correctly by their note argument. --- crates/pi-natives/src/grep.rs | 627 ++++++++++-------- .../session/session-history-format.test.ts | 29 + 2 files changed, 375 insertions(+), 281 deletions(-) diff --git a/crates/pi-natives/src/grep.rs b/crates/pi-natives/src/grep.rs index d20680f8c..dc09f19fd 100644 --- a/crates/pi-natives/src/grep.rs +++ b/crates/pi-natives/src/grep.rs @@ -946,276 +946,6 @@ fn build_regex_matcher( .build(pattern) } -#[cfg(test)] -mod tests { - #[cfg(unix)] - use std::{ffi::CString, os::unix::ffi::OsStrExt}; - use std::{ - fs, - path::{Path, PathBuf}, - sync::atomic::{AtomicU64, Ordering}, - time::{Duration, SystemTime, UNIX_EPOCH}, - }; - - use super::{ - GrepConfig, GrepOutputMode, escape_unescaped_parentheses, grep_sync, sanitize_braces, - }; - use crate::task; - - struct TempDirGuard(PathBuf); - - impl TempDirGuard { - fn new() -> Self { - static COUNTER: AtomicU64 = AtomicU64::new(0); - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time is after UNIX_EPOCH") - .as_nanos(); - let seq = COUNTER.fetch_add(1, Ordering::Relaxed); - let pid = std::process::id(); - let path = std::env::temp_dir().join(format!("pi-grep-test-{pid}-{nanos}-{seq}")); - fs::create_dir_all(&path).expect("create temp test directory"); - Self(path) - } - - fn path(&self) -> &Path { - &self.0 - } - } - - impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - fn write_file(path: &Path, content: &str) { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("create parent directories for test file"); - } - fs::write(path, content).expect("write test file"); - } - - #[cfg(unix)] - fn make_fifo(path: &Path) { - let fifo_path = - CString::new(path.as_os_str().as_bytes()).expect("fifo path has no NUL bytes"); - // SAFETY: `fifo_path` is a valid CString (NUL-terminated, no interior NULs), - // so `as_ptr()` yields a valid C string pointer. `0o600` is a valid mode. - // The CString is alive for the duration of the call. - let rc = unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) }; - assert_eq!(rc, 0, "create fifo: {}", std::io::Error::last_os_error()); - } - - #[cfg(unix)] - fn base_grep_config(path: &Path) -> GrepConfig { - GrepConfig { - pattern: "needle".to_string(), - path: path.to_string_lossy().into_owned(), - glob: None, - type_filter: None, - ignore_case: None, - multiline: None, - hidden: None, - gitignore: Some(false), - cache: Some(false), - max_count: None, - offset: None, - context_before: None, - context_after: None, - context: None, - max_columns: None, - mode: None, - max_count_per_file: None, - } - } - - #[test] - fn preserves_unicode_property_escapes() { - assert_eq!(sanitize_braces(r"\p{Greek}").as_ref(), r"\p{Greek}"); - } - - #[test] - fn preserves_hex_brace_escapes() { - assert_eq!(sanitize_braces(r"\x{41}").as_ref(), r"\x{41}"); - } - - #[test] - fn preserves_malformed_braced_escapes() { - assert_eq!(sanitize_braces(r"\p{Greek").as_ref(), r"\p{Greek"); - } - - #[test] - fn escapes_non_quantifier_braces() { - assert_eq!(sanitize_braces("${platform}").as_ref(), "$\\{platform\\}"); - } - - #[test] - fn preserves_valid_quantifiers() { - assert_eq!(sanitize_braces("a{2,4}").as_ref(), "a{2,4}"); - } - - #[test] - fn preserves_escaped_parentheses() { - assert_eq!(escape_unescaped_parentheses(r"foo\(bar\)").as_ref(), r"foo\(bar\)"); - } - - #[test] - fn escapes_literal_parentheses() { - assert_eq!( - escape_unescaped_parentheses("fetchAnthropicProvider(").as_ref(), - r"fetchAnthropicProvider\(" - ); - assert_eq!( - escape_unescaped_parentheses("fetchAnthropicProvider()").as_ref(), - r"fetchAnthropicProvider\(\)" - ); - } - - #[cfg(unix)] - #[test] - fn grep_directory_skips_fifo_entries() { - let root = TempDirGuard::new(); - write_file(&root.path().join("regular.txt"), "needle\n"); - make_fifo(&root.path().join("skip-me.fifo")); - - let result = grep_sync(base_grep_config(root.path()), None, task::CancelToken::default()) - .expect("directory grep should succeed"); - - assert_eq!(result.total_matches, 1); - assert_eq!(result.files_with_matches, 1); - assert_eq!(result.files_searched, 1); - assert_eq!(result.matches.len(), 1); - assert_eq!(result.matches[0].path, "regular.txt"); - } - - #[cfg(unix)] - #[test] - fn grep_directory_applies_offset_and_limit_in_walker_order() { - let root = TempDirGuard::new(); - write_file(&root.path().join("a.txt"), "needle a1\nneedle a2\n"); - write_file(&root.path().join("b.txt"), "needle b1\n"); - write_file(&root.path().join("c.txt"), "haystack\n"); - - let mut config = base_grep_config(root.path()); - config.max_count = Some(2); - config.offset = Some(1); - - let result = grep_sync(config, None, task::CancelToken::default()) - .expect("directory grep should succeed"); - - assert_eq!(result.total_matches, 3); - assert_eq!(result.files_with_matches, 2); - assert_eq!(result.limit_reached, Some(true)); - assert_eq!(result.matches.len(), 2); - assert_eq!(result.matches[0].path, "a.txt"); - assert_eq!(result.matches[0].line, "needle a2"); - assert_eq!(result.matches[1].path, "b.txt"); - assert_eq!(result.matches[1].line, "needle b1"); - } - - #[cfg(unix)] - #[test] - fn grep_count_mode_limit_applies_to_matches_not_files() { - let root = TempDirGuard::new(); - write_file(&root.path().join("a.txt"), "needle a1\nneedle a2\n"); - write_file(&root.path().join("b.txt"), "needle b1\n"); - - let mut config = base_grep_config(root.path()); - config.mode = Some(GrepOutputMode::Count); - config.max_count = Some(2); - - let result = grep_sync(config, None, task::CancelToken::default()) - .expect("directory grep should succeed"); - - assert_eq!(result.total_matches, 3); - assert_eq!(result.files_with_matches, 2); - assert_eq!(result.limit_reached, Some(true)); - assert_eq!(result.matches.len(), 1); - assert_eq!(result.matches[0].path, "a.txt"); - assert_eq!(result.matches[0].match_count, Some(2)); - } - - #[cfg(unix)] - #[test] - fn grep_streaming_respects_pre_cancelled_token() { - let root = TempDirGuard::new(); - write_file(&root.path().join("regular.txt"), "needle\n"); - - let ct = task::CancelToken::new(Some(0), None); - std::thread::sleep(Duration::from_millis(1)); - let result = grep_sync(base_grep_config(root.path()), None, ct); - - let Err(err) = result else { - panic!("pre-cancelled grep should fail before returning matches"); - }; - assert!( - err.to_string().contains("Timeout"), - "expected timeout cancellation error, got: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn grep_special_root_path_returns_empty_result() { - let root = TempDirGuard::new(); - let fifo = root.path().join("direct.fifo"); - make_fifo(&fifo); - - let result = grep_sync(base_grep_config(&fifo), None, task::CancelToken::default()) - .expect("special-file grep should return an empty result"); - - assert!(result.matches.is_empty()); - assert_eq!(result.total_matches, 0); - assert_eq!(result.files_with_matches, 0); - assert_eq!(result.files_searched, 0); - assert_eq!(result.limit_reached, None); - } - - #[cfg(unix)] - #[test] - fn grep_multiline_matches_cross_line_patterns() { - let root = TempDirGuard::new(); - write_file(&root.path().join("code.txt"), "fn foo() {\n return 1;\n}\n"); - - let mut config = base_grep_config(root.path()); - config.pattern = r"foo\(\) \{\n return".to_string(); - config.multiline = Some(true); - - let result = grep_sync(config, None, task::CancelToken::default()) - .expect("multiline grep should succeed"); - - assert_eq!(result.total_matches, 1, "cross-line pattern should match across lines"); - assert_eq!(result.matches.len(), 1); - assert_eq!(result.matches[0].path, "code.txt"); - assert_eq!(result.matches[0].line_number, 1); - } - - #[cfg(unix)] - #[test] - fn grep_per_file_max_count_preserves_file_diversity() { - let root = TempDirGuard::new(); - write_file(&root.path().join("a.txt"), "needle 1\nneedle 2\nneedle 3\nneedle 4\nneedle 5\n"); - write_file(&root.path().join("z.txt"), "needle z\n"); - - let mut config = base_grep_config(root.path()); - config.max_count = Some(4); - config.max_count_per_file = Some(2); - - let result = grep_sync(config, None, task::CancelToken::default()) - .expect("directory grep should succeed"); - - let paths: Vec<&str> = result - .matches - .iter() - .map(|matched| matched.path.as_str()) - .collect(); - assert_eq!(paths, ["a.txt", "a.txt", "z.txt"], "hot file must not starve later files"); - assert_eq!(result.files_with_matches, 2); - assert_eq!(result.limit_reached, Some(true)); - } -} - fn build_matcher( pattern: &str, ignore_case: bool, @@ -1267,7 +997,7 @@ fn run_parallel_search( files_searched: &AtomicU64, ) -> Vec { let file_params = per_file_params(params); - let raw: Vec> = entries + entries .par_iter() .map_init( || build_searcher_for_params(file_params), @@ -1303,9 +1033,8 @@ fn run_parallel_search( }) }, ) - .collect(); - - raw.into_iter().flatten().collect() + .filter_map(std::convert::identity) + .collect() } struct StreamingGrepVisitor<'a> { @@ -1319,6 +1048,7 @@ struct StreamingGrepVisitor<'a> { shared_results: Arc>>>, error: Arc>>, skipped_oversized: Arc, + files_searched: Arc, ct: &'a task::CancelToken, visited: usize, } @@ -1381,6 +1111,7 @@ impl ParallelVisitor for StreamingGrepVisitor<'_> { }, Ok(ReadFile::Skipped) | Err(_) => return WalkState::Continue, }; + self.files_searched.fetch_add(1, Ordering::Relaxed); let search = if self.params.mode == OutputMode::FilesWithMatches { let Ok(matched) = self.matcher.is_match(bytes.as_slice()) else { return WalkState::Continue; @@ -1400,6 +1131,9 @@ impl ParallelVisitor for StreamingGrepVisitor<'_> { search }; + if search.match_count == 0 { + return WalkState::Continue; + } self.results.push(FileSearchResult { relative_path: relative.into_owned(), matches: search.matches, @@ -1419,6 +1153,7 @@ struct StreamingGrepVisitorBuilder<'a> { shared_results: Arc>>>, error: Arc>>, skipped_oversized: Arc, + files_searched: Arc, ct: &'a task::CancelToken, } @@ -1435,6 +1170,7 @@ impl<'a> ParallelVisitorBuilder<'a> for StreamingGrepVisitorBuilder<'a> { shared_results: Arc::clone(&self.shared_results), error: Arc::clone(&self.error), skipped_oversized: Arc::clone(&self.skipped_oversized), + files_searched: Arc::clone(&self.files_searched), ct: self.ct, visited: 0, }) @@ -1451,7 +1187,7 @@ fn run_streaming_grep( use_gitignore: bool, skip_node_modules: bool, ct: &task::CancelToken, -) -> Result<(Vec, u64)> { +) -> Result<(Vec, u64, u64)> { let mut builder = fs_cache::build_walker(search_path, include_hidden, use_gitignore, skip_node_modules, false); let workers = fs_cache::grep_workers(); @@ -1462,6 +1198,7 @@ fn run_streaming_grep( let shared_results = Arc::new(Mutex::new(Vec::new())); let error = Arc::new(Mutex::new(None)); let skipped_oversized = Arc::new(AtomicU64::new(0)); + let files_searched = Arc::new(AtomicU64::new(0)); let mut visitor_builder = StreamingGrepVisitorBuilder { root: search_path, matcher, @@ -1471,6 +1208,7 @@ fn run_streaming_grep( shared_results: Arc::clone(&shared_results), error: Arc::clone(&error), skipped_oversized: Arc::clone(&skipped_oversized), + files_searched: Arc::clone(&files_searched), ct, }; ct.heartbeat()?; @@ -1488,7 +1226,7 @@ fn run_streaming_grep( .flatten() .collect(); results.sort_unstable_by(|a, b| a.relative_path.cmp(&b.relative_path)); - Ok((results, skipped_oversized.load(Ordering::Relaxed))) + Ok((results, skipped_oversized.load(Ordering::Relaxed), files_searched.load(Ordering::Relaxed))) } fn push_count_match(matches: &mut Vec, path: String, match_count: u64) { @@ -1518,12 +1256,13 @@ fn push_file_match(matches: &mut Vec, path: String) { fn aggregate_parallel_results( results: Vec, params: SearchParams, + files_searched: u64, ) -> (Vec, u64, u32, u32, bool) { let SearchParams { mode, max_count, offset, .. } = params; let mut matches = Vec::new(); let mut total_matches = 0u64; let mut files_with_matches = 0u32; - let files_searched = crate::utils::clamp_u32(results.len() as u64); + let files_searched = crate::utils::clamp_u32(files_searched); let mut skipped = 0u64; let mut emitted = 0u64; let mut limit_reached = false; @@ -1877,8 +1616,9 @@ fn grep_sync( }); } let skipped = AtomicU64::new(0); - let results = run_parallel_search(&entries, &matcher, params, &skipped); - (results, skipped.load(Ordering::Relaxed)) + let searched = AtomicU64::new(0); + let results = run_parallel_search(&entries, &matcher, params, &skipped, &searched); + (results, skipped.load(Ordering::Relaxed), searched.load(Ordering::Relaxed)) } else { run_streaming_grep( &search_path, @@ -1892,9 +1632,9 @@ fn grep_sync( &ct, )? }; - let (results, skipped_oversized) = results; + let (results, skipped_oversized, files_searched) = results; let (matches, total_matches, files_with_matches, files_searched, limit_reached) = - aggregate_parallel_results(results, params); + aggregate_parallel_results(results, params, files_searched); // Fire callbacks after aggregation so offset/limit semantics match returned // results. @@ -2050,3 +1790,328 @@ pub fn grep( let ct = task::CancelToken::new(timeout_ms, signal); task::blocking("grep", ct, move |ct| grep_sync(config, on_match.as_ref(), ct)) } + +#[cfg(test)] +mod tests { + #[cfg(unix)] + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + use std::{ + fs, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, + time::{Duration, SystemTime, UNIX_EPOCH}, + }; + + use super::{ + GrepConfig, GrepOutputMode, escape_unescaped_parentheses, grep_sync, sanitize_braces, + }; + use crate::task; + + struct TempDirGuard(PathBuf); + + impl TempDirGuard { + fn new() -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time is after UNIX_EPOCH") + .as_nanos(); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let pid = std::process::id(); + let path = std::env::temp_dir().join(format!("pi-grep-test-{pid}-{nanos}-{seq}")); + fs::create_dir_all(&path).expect("create temp test directory"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn write_file(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent directories for test file"); + } + fs::write(path, content).expect("write test file"); + } + + #[cfg(unix)] + fn make_fifo(path: &Path) { + let fifo_path = + CString::new(path.as_os_str().as_bytes()).expect("fifo path has no NUL bytes"); + // SAFETY: `fifo_path` is a valid CString (NUL-terminated, no interior NULs), + // so `as_ptr()` yields a valid C string pointer. `0o600` is a valid mode. + // The CString is alive for the duration of the call. + let rc = unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) }; + assert_eq!(rc, 0, "create fifo: {}", std::io::Error::last_os_error()); + } + + #[cfg(unix)] + fn base_grep_config(path: &Path) -> GrepConfig { + GrepConfig { + pattern: "needle".to_string(), + path: path.to_string_lossy().into_owned(), + glob: None, + type_filter: None, + ignore_case: None, + multiline: None, + hidden: None, + gitignore: Some(false), + cache: Some(false), + max_count: None, + offset: None, + context_before: None, + context_after: None, + context: None, + max_columns: None, + mode: None, + max_count_per_file: None, + } + } + + #[test] + fn preserves_unicode_property_escapes() { + assert_eq!(sanitize_braces(r"\p{Greek}").as_ref(), r"\p{Greek}"); + } + + #[test] + fn preserves_hex_brace_escapes() { + assert_eq!(sanitize_braces(r"\x{41}").as_ref(), r"\x{41}"); + } + + #[test] + fn preserves_malformed_braced_escapes() { + assert_eq!(sanitize_braces(r"\p{Greek").as_ref(), r"\p{Greek"); + } + + #[test] + fn escapes_non_quantifier_braces() { + assert_eq!(sanitize_braces("${platform}").as_ref(), "$\\{platform\\}"); + } + + #[test] + fn preserves_valid_quantifiers() { + assert_eq!(sanitize_braces("a{2,4}").as_ref(), "a{2,4}"); + } + + #[test] + fn preserves_escaped_parentheses() { + assert_eq!(escape_unescaped_parentheses(r"foo\(bar\)").as_ref(), r"foo\(bar\)"); + } + + #[test] + fn escapes_literal_parentheses() { + assert_eq!( + escape_unescaped_parentheses("fetchAnthropicProvider(").as_ref(), + r"fetchAnthropicProvider\(" + ); + assert_eq!( + escape_unescaped_parentheses("fetchAnthropicProvider()").as_ref(), + r"fetchAnthropicProvider\(\)" + ); + } + + #[cfg(unix)] + #[test] + fn grep_directory_skips_fifo_entries() { + let root = TempDirGuard::new(); + write_file(&root.path().join("regular.txt"), "needle\n"); + make_fifo(&root.path().join("skip-me.fifo")); + + let result = grep_sync(base_grep_config(root.path()), None, task::CancelToken::default()) + .expect("directory grep should succeed"); + + assert_eq!(result.total_matches, 1); + assert_eq!(result.files_with_matches, 1); + assert_eq!(result.files_searched, 1); + assert_eq!(result.matches.len(), 1); + assert_eq!(result.matches[0].path, "regular.txt"); + } + + #[cfg(unix)] + #[test] + fn grep_directory_counts_searched_files_without_storing_no_match_results() { + let root = TempDirGuard::new(); + write_file(&root.path().join("a.txt"), "needle\n"); + write_file(&root.path().join("b.txt"), "haystack\n"); + + let result = grep_sync(base_grep_config(root.path()), None, task::CancelToken::default()) + .expect("directory grep should succeed"); + + assert_eq!(result.total_matches, 1); + assert_eq!(result.files_with_matches, 1); + assert_eq!(result.files_searched, 2); + assert_eq!(result.matches.len(), 1); + assert_eq!(result.matches[0].path, "a.txt"); + } + + #[cfg(unix)] + #[test] + fn grep_cached_directory_counts_searched_files_without_storing_no_match_results() { + let root = TempDirGuard::new(); + write_file(&root.path().join("a.txt"), "needle\n"); + write_file(&root.path().join("b.txt"), "haystack\n"); + + let mut config = base_grep_config(root.path()); + config.cache = Some(true); + let result = grep_sync(config, None, task::CancelToken::default()) + .expect("cached directory grep should succeed"); + + assert_eq!(result.total_matches, 1); + assert_eq!(result.files_with_matches, 1); + assert_eq!(result.files_searched, 2); + assert_eq!(result.matches.len(), 1); + assert_eq!(result.matches[0].path, "a.txt"); + } + + #[cfg(unix)] + #[test] + fn grep_files_with_matches_counts_all_searched_files_when_none_match() { + let root = TempDirGuard::new(); + write_file(&root.path().join("a.txt"), "haystack a\n"); + write_file(&root.path().join("b.txt"), "haystack b\n"); + + let mut config = base_grep_config(root.path()); + config.mode = Some(GrepOutputMode::FilesWithMatches); + + let result = grep_sync(config, None, task::CancelToken::default()) + .expect("directory grep should succeed"); + + assert_eq!(result.total_matches, 0); + assert_eq!(result.files_with_matches, 0); + assert_eq!(result.files_searched, 2); + assert!(result.matches.is_empty()); + } + + #[cfg(unix)] + #[test] + fn grep_directory_applies_offset_and_limit_in_walker_order() { + let root = TempDirGuard::new(); + write_file(&root.path().join("a.txt"), "needle a1\nneedle a2\n"); + write_file(&root.path().join("b.txt"), "needle b1\n"); + write_file(&root.path().join("c.txt"), "haystack\n"); + + let mut config = base_grep_config(root.path()); + config.max_count = Some(2); + config.offset = Some(1); + + let result = grep_sync(config, None, task::CancelToken::default()) + .expect("directory grep should succeed"); + + assert_eq!(result.total_matches, 3); + assert_eq!(result.files_with_matches, 2); + assert_eq!(result.limit_reached, Some(true)); + assert_eq!(result.matches.len(), 2); + assert_eq!(result.matches[0].path, "a.txt"); + assert_eq!(result.matches[0].line, "needle a2"); + assert_eq!(result.matches[1].path, "b.txt"); + assert_eq!(result.matches[1].line, "needle b1"); + } + + #[cfg(unix)] + #[test] + fn grep_count_mode_limit_applies_to_matches_not_files() { + let root = TempDirGuard::new(); + write_file(&root.path().join("a.txt"), "needle a1\nneedle a2\n"); + write_file(&root.path().join("b.txt"), "needle b1\n"); + + let mut config = base_grep_config(root.path()); + config.mode = Some(GrepOutputMode::Count); + config.max_count = Some(2); + + let result = grep_sync(config, None, task::CancelToken::default()) + .expect("directory grep should succeed"); + + assert_eq!(result.total_matches, 3); + assert_eq!(result.files_with_matches, 2); + assert_eq!(result.limit_reached, Some(true)); + assert_eq!(result.matches.len(), 1); + assert_eq!(result.matches[0].path, "a.txt"); + assert_eq!(result.matches[0].match_count, Some(2)); + } + + #[cfg(unix)] + #[test] + fn grep_streaming_respects_pre_cancelled_token() { + let root = TempDirGuard::new(); + write_file(&root.path().join("regular.txt"), "needle\n"); + + let ct = task::CancelToken::new(Some(0), None); + std::thread::sleep(Duration::from_millis(1)); + let result = grep_sync(base_grep_config(root.path()), None, ct); + + let Err(err) = result else { + panic!("pre-cancelled grep should fail before returning matches"); + }; + assert!( + err.to_string().contains("Timeout"), + "expected timeout cancellation error, got: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn grep_special_root_path_returns_empty_result() { + let root = TempDirGuard::new(); + let fifo = root.path().join("direct.fifo"); + make_fifo(&fifo); + + let result = grep_sync(base_grep_config(&fifo), None, task::CancelToken::default()) + .expect("special-file grep should return an empty result"); + + assert!(result.matches.is_empty()); + assert_eq!(result.total_matches, 0); + assert_eq!(result.files_with_matches, 0); + assert_eq!(result.files_searched, 0); + assert_eq!(result.limit_reached, None); + } + + #[cfg(unix)] + #[test] + fn grep_multiline_matches_cross_line_patterns() { + let root = TempDirGuard::new(); + write_file(&root.path().join("code.txt"), "fn foo() {\n return 1;\n}\n"); + + let mut config = base_grep_config(root.path()); + config.pattern = r"foo\(\) \{\n return".to_string(); + config.multiline = Some(true); + + let result = grep_sync(config, None, task::CancelToken::default()) + .expect("multiline grep should succeed"); + + assert_eq!(result.total_matches, 1, "cross-line pattern should match across lines"); + assert_eq!(result.matches.len(), 1); + assert_eq!(result.matches[0].path, "code.txt"); + assert_eq!(result.matches[0].line_number, 1); + } + + #[cfg(unix)] + #[test] + fn grep_per_file_max_count_preserves_file_diversity() { + let root = TempDirGuard::new(); + write_file(&root.path().join("a.txt"), "needle 1\nneedle 2\nneedle 3\nneedle 4\nneedle 5\n"); + write_file(&root.path().join("z.txt"), "needle z\n"); + + let mut config = base_grep_config(root.path()); + config.max_count = Some(4); + config.max_count_per_file = Some(2); + + let result = grep_sync(config, None, task::CancelToken::default()) + .expect("directory grep should succeed"); + + let paths: Vec<&str> = result + .matches + .iter() + .map(|matched| matched.path.as_str()) + .collect(); + assert_eq!(paths, ["a.txt", "a.txt", "z.txt"], "hot file must not starve later files"); + assert_eq!(result.files_with_matches, 2); + assert_eq!(result.limit_reached, Some(true)); + } +} diff --git a/packages/coding-agent/test/session/session-history-format.test.ts b/packages/coding-agent/test/session/session-history-format.test.ts index 7a377dc5d..79fdadd60 100644 --- a/packages/coding-agent/test/session/session-history-format.test.ts +++ b/packages/coding-agent/test/session/session-history-format.test.ts @@ -163,4 +163,33 @@ describe("formatSessionHistoryMarkdown", () => { expect(outputWithoutIntent).not.toContain("// reading config file"); expect(outputWithoutIntent).toContain("→ read(src/config.ts) ⇒ ok · 1 line"); }); + it("summarizes advise tool calls by their note, not severity or result", () => { + const messages = [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "tc-advise-1", + name: "advise", + arguments: { severity: "concern", note: "Avoid shadowing the outer variable." }, + }, + ], + timestamp: 1, + }, + { + role: "toolResult", + toolCallId: "tc-advise-1", + toolName: "advise", + content: [{ type: "text", text: "Recorded." }], + isError: false, + timestamp: 2, + }, + ]; + + const output = formatSessionHistoryMarkdown(messages); + expect(output).toContain("→ advise(Avoid shadowing the outer variable.) ⇒ ok · 1 line"); + expect(output).not.toContain("→ advise(concern)"); + expect(output).not.toContain("Recorded."); + }); });