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:
+346
-281
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user