use std::path::Path;
const RECURSIVE_TOOLS: &[&str] = &["grep", "egrep", "fgrep", "find", "ack", "ag"];
const BULK_DIRS: &[&str] = &[
".worktrees",
"worktrees",
".codex-worktrees",
"node_modules",
"target",
"vendor",
".venv",
"venv",
"site-packages",
".next",
".cache",
];
const SCAN_DEPTH: usize = 3;
const COUNT_CAP: usize = 5_000;
pub(crate) fn walk_hint(command: &str, cwd: &str) -> Option<String> {
if !is_recursive_walk(command) {
return None;
}
let moved = lean_ctx::core::command_cwd::final_cwd(command, Some(Path::new(cwd)));
let base: &Path = match moved.as_deref() {
Some(dir) => dir,
None if command.split_whitespace().any(|w| w == "cd") => return None,
None => Path::new(cwd),
};
let mut found: Vec<(String, usize, bool)> = Vec::new();
for root in walk_roots(command, base) {
collect_bulk_dirs(&root, &root, 0, &mut found);
}
found.sort_by(|a, b| a.0.cmp(&b.0));
found.dedup_by(|a, b| a.0 == b.0);
found.retain(|(rel, _, _)| !command.contains(rel.as_str()));
if found.is_empty() {
return None;
}
found.sort_by_key(|f| std::cmp::Reverse(f.1));
found.truncate(3);
let named: Vec<String> = found
.iter()
.map(|(rel, n, capped)| format!("{rel}/ ({n}{} files)", if *capped { "+" } else { "" }))
.collect();
Some(format!(
"[hint: {} under this path — walked by grep/find, but ignored by \
ctx_search and ctx_glob, which honour .gitignore. Scope the path, add \
--exclude-dir, or use ctx_search.]",
named.join(", ")
))
}
pub(crate) fn strip_walk_hint(output: &str) -> &str {
match output.rfind("[hint: ") {
Some(idx)
if output[idx..].contains("walked by grep/find")
&& output[idx..].trim_end().ends_with("or use ctx_search.]") =>
{
&output[..idx]
}
_ => output,
}
}
fn walk_roots(command: &str, base: &Path) -> Vec<std::path::PathBuf> {
let mut roots = Vec::new();
for segment in command.split(|c| matches!(c, '|' | ';' | '&')) {
let Some(tool) = recursive_tool(segment) else {
continue;
};
let mut words = segment.split_whitespace().skip_while(|w| w.contains('='));
words.next();
let is_find = tool == "find";
let mut operands: Vec<&str> = Vec::new();
for word in words {
if word.starts_with('-') {
if is_find {
break;
}
continue;
}
operands.push(word);
}
let paths = if is_find || operands.is_empty() {
operands.as_slice()
} else {
&operands[1..]
};
for path in paths {
let trimmed = path.trim_matches(['"', '\'']);
if trimmed.is_empty() || trimmed.contains('$') {
continue;
}
let candidate = Path::new(trimmed);
roots.push(if candidate.is_absolute() || trimmed.starts_with('/') {
candidate.to_path_buf()
} else {
lean_ctx::core::command_cwd::join_in(base, trimmed)
});
}
if paths.is_empty() {
roots.push(base.to_path_buf());
}
}
if roots.is_empty() {
roots.push(base.to_path_buf());
}
roots
}
fn recursive_tool(segment: &str) -> Option<&str> {
let mut words = segment.split_whitespace().skip_while(|w| w.contains('='));
let tool = words.next()?;
let tool = tool.rsplit('/').next().unwrap_or(tool);
if !RECURSIVE_TOOLS.contains(&tool) {
return None;
}
let recursive = tool == "find"
|| words.any(|w| w.starts_with('-') && !w.starts_with("--") && w.contains(['r', 'R']));
recursive.then_some(tool)
}
fn is_recursive_walk(command: &str) -> bool {
command
.split(|c| matches!(c, '|' | ';' | '&'))
.any(|segment| recursive_tool(segment).is_some())
}
fn collect_bulk_dirs(root: &Path, dir: &Path, depth: usize, out: &mut Vec<(String, usize, bool)>) {
if depth > SCAN_DEPTH || out.len() >= 16 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if !entry.file_type().is_ok_and(|t| t.is_dir()) {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if BULK_DIRS.contains(&name.as_str()) {
let (count, capped) = count_files(&path);
if let Ok(rel) = path.strip_prefix(root) {
out.push((rel.to_string_lossy().into_owned(), count, capped));
}
continue; }
collect_bulk_dirs(root, &path, depth + 1, out);
}
}
fn count_files(dir: &Path) -> (usize, bool) {
let mut stack = vec![dir.to_path_buf()];
let mut count = 0usize;
while let Some(next) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&next) else {
continue;
};
for entry in entries.flatten() {
match entry.file_type() {
Ok(t) if t.is_dir() => stack.push(entry.path()),
Ok(_) => {
count += 1;
if count >= COUNT_CAP {
return (COUNT_CAP, true);
}
}
Err(_) => {}
}
}
}
(count, false)
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
fn make_tree(root: &Path, rel: &str, files: usize) {
let dir = root.join(rel);
std::fs::create_dir_all(&dir).unwrap();
for i in 0..files {
std::fs::write(dir.join(format!("f{i}.go")), b"x").unwrap();
}
}
#[test]
fn recognises_recursive_grep_but_not_rg() {
assert!(is_recursive_walk("grep -rn needle ."));
assert!(is_recursive_walk("grep -Rn needle ."));
assert!(is_recursive_walk("find . -name '*.go'"));
assert!(is_recursive_walk("cd /x && grep -rn a ."));
assert!(!is_recursive_walk("grep needle file.txt"));
assert!(!is_recursive_walk("rg needle"));
}
#[test]
fn names_nested_checkouts_with_real_counts() {
let t = tmp();
make_tree(t.path(), ".claude/worktrees/a", 12);
make_tree(t.path(), ".claude/worktrees/b", 8);
make_tree(t.path(), "src", 3);
let hint = walk_hint("grep -rn needle .", t.path().to_str().unwrap())
.expect("a hint for a recursive grep over a tree with nested checkouts");
assert!(hint.contains("worktrees"), "{hint}");
assert!(
hint.contains("20 files"),
"real count, not an estimate: {hint}"
);
assert!(hint.contains("ctx_search"), "{hint}");
assert!(!hint.contains('%'), "no invented percentage: {hint}");
}
#[test]
fn stays_quiet_when_the_command_already_excludes_it() {
let t = tmp();
make_tree(t.path(), ".worktrees/a", 5);
assert!(
walk_hint(
"grep -rn needle . | grep -v '.worktrees'",
t.path().to_str().unwrap()
)
.is_none()
);
}
#[test]
fn stays_quiet_without_bulk_directories() {
let t = tmp();
make_tree(t.path(), "src", 4);
assert!(walk_hint("grep -rn needle .", t.path().to_str().unwrap()).is_none());
}
#[test]
fn stays_quiet_for_a_non_walking_command() {
let t = tmp();
make_tree(t.path(), "node_modules/x", 5);
assert!(walk_hint("cargo test", t.path().to_str().unwrap()).is_none());
}
#[test]
fn a_capped_count_is_marked_as_a_floor() {
let (n, capped) = (COUNT_CAP, true);
let rendered = format!("{n}{} files", if capped { "+" } else { "" });
assert!(rendered.starts_with("5000+"), "{rendered}");
}
}
#[cfg(test)]
mod gh1662 {
use super::*;
fn repo_with_bulk() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
for bulk in ["node_modules", ".git"] {
let sub = dir.path().join(bulk);
std::fs::create_dir_all(&sub).expect("mkdir");
for i in 0..40 {
std::fs::write(sub.join(format!("f{i}.js")), "x").expect("write");
}
}
std::fs::write(dir.path().join("main.go"), "package main").expect("write");
dir
}
#[test]
fn a_piped_recursive_grep_is_still_a_recursive_walk() {
assert!(is_recursive_walk(
"grep -rn \"spine-go/mocks\" --include=*.go . | head"
));
}
#[test]
fn a_piped_recursive_grep_gets_the_hint() {
let repo = repo_with_bulk();
let hint = walk_hint(
"grep -rn \"mocks\" --include=*.go . | head",
&repo.path().to_string_lossy(),
);
let hint = hint.expect("bulk directories under the walked path must be named");
assert!(hint.contains("node_modules/"), "{hint}");
assert!(hint.contains("ctx_search"), "{hint}");
}
#[test]
fn the_hint_follows_a_leading_cd() {
let repo = repo_with_bulk();
let elsewhere = tempfile::tempdir().expect("tempdir");
let command = format!(
"cd {} && grep -rn \"mocks\" --include=*.go . | head",
repo.path().display()
);
let hint = walk_hint(&command, &elsewhere.path().to_string_lossy());
assert!(
hint.is_some_and(|h| h.contains("node_modules/")),
"the scan must follow the cd, not the call directory"
);
}
#[test]
fn an_unresolvable_cd_stays_silent() {
let repo = repo_with_bulk();
let hint = walk_hint(
"cd \"$REPO\" && grep -rn x . | head",
&repo.path().to_string_lossy(),
);
assert!(hint.is_none(), "must not guess: {hint:?}");
}
}
#[cfg(test)]
mod gh1662_scope {
use super::*;
fn repo_with_bulk() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let nm = dir.path().join("node_modules");
std::fs::create_dir_all(&nm).expect("mkdir");
for i in 0..40 {
std::fs::write(nm.join(format!("f{i}.js")), "x").expect("write");
}
std::fs::create_dir_all(dir.path().join("src")).expect("mkdir");
std::fs::write(dir.path().join("src/main.go"), "package main").expect("write");
dir
}
#[test]
fn a_scoped_walk_is_not_blamed_on_a_directory_outside_its_scope() {
let repo = repo_with_bulk();
let cwd = repo.path().to_string_lossy();
assert!(
walk_hint("grep -rn pattern src/", &cwd).is_none(),
"src/ holds no bulk directory"
);
assert!(
walk_hint("grep -rn pattern .", &cwd).is_some(),
"a walk rooted at the repo does hit node_modules"
);
}
#[test]
fn find_takes_its_paths_before_the_predicates() {
let repo = repo_with_bulk();
let cwd = repo.path().to_string_lossy();
assert!(walk_hint("find src -name '*.go'", &cwd).is_none());
assert!(walk_hint("find . -name '*.go'", &cwd).is_some());
}
#[test]
fn an_absolute_path_argument_is_used_as_is() {
let repo = repo_with_bulk();
let elsewhere = tempfile::tempdir().expect("tempdir");
let command = format!("grep -rn pattern {}", repo.path().display());
assert!(walk_hint(&command, &elsewhere.path().to_string_lossy()).is_some());
}
#[test]
fn an_unexpandable_path_falls_back_to_the_command_directory() {
let repo = repo_with_bulk();
let cwd = repo.path().to_string_lossy();
assert!(walk_hint("grep -rn pattern \"$DIR\"", &cwd).is_some());
}
}
#[cfg(test)]
mod gh1680 {
use super::*;
#[test]
fn an_explicit_path_outside_the_root_is_not_blamed_on_the_cwd() {
let repo = tempfile::tempdir().expect("tempdir");
let bulk = repo.path().join("node_modules");
std::fs::create_dir_all(&bulk).expect("mkdir");
for i in 0..40 {
std::fs::write(bulk.join(format!("f{i}.js")), "x").expect("write");
}
let elsewhere = tempfile::tempdir().expect("tempdir");
let target = elsewhere.path().join("types.go");
std::fs::write(&target, "package main").expect("write");
let command = format!("grep -rn \"EvConnected\" {}", target.display());
let hint = walk_hint(&command, &repo.path().to_string_lossy());
assert!(
hint.is_none(),
"grep read one file outside the root; nothing under the cwd was walked: {hint:?}"
);
}
#[test]
fn the_intended_case_still_fires() {
let repo = tempfile::tempdir().expect("tempdir");
let bulk = repo.path().join("node_modules");
std::fs::create_dir_all(&bulk).expect("mkdir");
for i in 0..40 {
std::fs::write(bulk.join(format!("f{i}.js")), "x").expect("write");
}
std::fs::write(repo.path().join("main.go"), "package main").expect("write");
let hint = walk_hint(
"grep -rn \"EvConnected\" --include=*.go .",
&repo.path().to_string_lossy(),
);
assert!(
hint.is_some_and(|h| h.contains("node_modules/")),
"a walk rooted at the repo does traverse node_modules"
);
}
#[test]
fn a_single_file_operand_never_produces_a_hint() {
let repo = tempfile::tempdir().expect("tempdir");
let bulk = repo.path().join("node_modules");
std::fs::create_dir_all(&bulk).expect("mkdir");
for i in 0..40 {
std::fs::write(bulk.join(format!("f{i}.js")), "x").expect("write");
}
let file = repo.path().join("main.go");
std::fs::write(&file, "package main").expect("write");
let hint = walk_hint(
&format!("grep -rn x {}", file.display()),
&repo.path().to_string_lossy(),
);
assert!(hint.is_none(), "{hint:?}");
}
#[test]
fn a_later_stdin_grep_does_not_turn_a_scoped_walk_into_a_cwd_walk() {
let repo = tempfile::tempdir().expect("tempdir");
for (rel, files) in [
(".claude/worktrees/a", 6),
("node_modules/x", 6),
("tariff", 2),
] {
let dir = repo.path().join(rel);
std::fs::create_dir_all(&dir).expect("mkdir");
for i in 0..files {
std::fs::write(dir.join(format!("f{i}.go")), "x").expect("write");
}
}
let cwd = repo.path().to_string_lossy().to_string();
for quiet in [
"grep -rn 'func mergeRates' tariff",
"grep -rn 'func mergeRates' tariff | head -5",
"grep -rn 'func mergeRates' tariff; echo done",
"grep -n 'func mergeRates' tariff/f0.go; echo helper | grep -c helper",
"grep -rn 'func mergeRates' tariff; echo helper | grep -c helper",
"grep -rn 'func mergeRates' tariff; ls tariff | grep -c helper",
"grep -rn x tariff | grep -v _test",
] {
assert!(walk_hint(quiet, &cwd).is_none(), "{quiet}");
}
let hint = walk_hint("grep -rn x tariff; grep -rn y .", &cwd).expect("cwd walk");
assert!(hint.contains("node_modules/"), "{hint}");
let out = format!("tariff/a.go:1:x\n{hint}");
assert_eq!(strip_walk_hint(&out), "tariff/a.go:1:x\n");
assert_eq!(
strip_walk_hint("[hint: something else]"),
"[hint: something else]"
);
}
}