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.
This commit is contained in:
can1357
2026-06-18 02:06:43 +02:00
parent 4c36a48eb7
commit 566999854a
2 changed files with 375 additions and 281 deletions
+346 -281
View File
@@ -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<FileSearchResult> {
let file_params = per_file_params(params);
let raw: Vec<Option<FileSearchResult>> = 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<Mutex<Vec<Vec<FileSearchResult>>>>,
error: Arc<Mutex<Option<String>>>,
skipped_oversized: Arc<AtomicU64>,
files_searched: Arc<AtomicU64>,
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<Mutex<Vec<Vec<FileSearchResult>>>>,
error: Arc<Mutex<Option<String>>>,
skipped_oversized: Arc<AtomicU64>,
files_searched: Arc<AtomicU64>,
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<FileSearchResult>, u64)> {
) -> Result<(Vec<FileSearchResult>, 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<GrepMatch>, path: String, match_count: u64) {
@@ -1518,12 +1256,13 @@ fn push_file_match(matches: &mut Vec<GrepMatch>, path: String) {
fn aggregate_parallel_results(
results: Vec<FileSearchResult>,
params: SearchParams,
files_searched: u64,
) -> (Vec<GrepMatch>, 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));
}
}
@@ -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.");
});
});