use super::*;
async fn run_find(args: serde_json::Value, ws: &std::path::Path) -> String {
run_tool("find", args, ws, &caveats_rw(ws), None).await
}
#[tokio::test]
async fn find_locates_file_by_name_issue_496() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "newt-core/src/pyo3_module.rs");
touch(ws.path(), "newt-data/src/other.rs");
touch(ws.path(), "docs/pyo3_module.md"); let out = run_find(serde_json::json!({ "name": "pyo3_module.rs" }), ws.path()).await;
assert_eq!(out, "newt-core/src/pyo3_module.rs", "got: {out}");
}
#[tokio::test]
async fn find_code_true_excludes_docs_and_lockfiles_from_line_ranking() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("tall.rs"), "x\n".repeat(20)).unwrap();
std::fs::write(ws.path().join("short.rs"), "x\n".repeat(5)).unwrap();
std::fs::write(ws.path().join("AGENTS.md"), "d\n".repeat(200)).unwrap();
std::fs::write(ws.path().join("Cargo.lock"), "l\n".repeat(100)).unwrap();
std::fs::write(ws.path().join("LICENSE"), "L\n".repeat(50)).unwrap();
let out = run_find(
serde_json::json!({
"path": ".",
"type": "f",
"code": true,
"sort": "lines",
"show_lines": true,
"max_results": 10
}),
ws.path(),
)
.await;
assert!(
out.contains("20\ttall.rs") && out.contains("5\tshort.rs"),
"code sources with line counts: {out}"
);
assert!(
!out.contains("AGENTS.md") && !out.contains("Cargo.lock") && !out.contains("LICENSE"),
"docs/lockfiles/LICENSE must be excluded: {out}"
);
let tall = out.find("20\ttall.rs").expect("tall first");
let short = out.find("5\tshort.rs").expect("short second");
assert!(tall < short, "lines descending: {out}");
}
#[tokio::test]
async fn find_glob_type_and_maxdepth_together() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "examples/a.py"); touch(ws.path(), "examples/sub/b.py"); touch(ws.path(), "examples/sub/deep/c.py"); touch(ws.path(), "examples/readme.md"); std::fs::create_dir_all(ws.path().join("examples/empty_dir")).unwrap();
let out = run_find(
serde_json::json!({
"path": "examples", "name": "*.py", "type": "f", "max_depth": 2
}),
ws.path(),
)
.await;
assert_eq!(out, "examples/a.py\nexamples/sub/b.py", "got: {out}");
}
#[tokio::test]
async fn find_source_category_filters_repository_metadata_across_languages() {
let ws = tempfile::TempDir::new().unwrap();
for file in [
"src/main.rs",
"src/app.py",
"web/app.ts",
"java/App.java",
"native/app.cpp",
"dotnet/App.cs",
"ruby/app.rb",
"scripts/build.sh",
"AGENTS.md",
"Cargo.toml",
"Cargo.lock",
] {
touch(ws.path(), file);
}
let out = run_find(
serde_json::json!({ "category": "source", "type": "f" }),
ws.path(),
)
.await;
for source in [
"src/main.rs",
"src/app.py",
"web/app.ts",
"java/App.java",
"native/app.cpp",
"dotnet/App.cs",
"ruby/app.rb",
"scripts/build.sh",
] {
assert!(
out.lines().any(|line| line == source),
"missing {source}: {out}"
);
}
for metadata in ["AGENTS.md", "Cargo.toml", "Cargo.lock"] {
assert!(
!out.lines().any(|line| line == metadata),
"metadata is not source code ({metadata}): {out}"
);
}
}
#[tokio::test]
async fn find_language_alias_narrows_source_files() {
let ws = tempfile::TempDir::new().unwrap();
for file in ["native/a.c", "native/b.cpp", "dotnet/App.cs", "src/main.rs"] {
touch(ws.path(), file);
}
let cpp = run_find(serde_json::json!({ "language": "C++" }), ws.path()).await;
assert_eq!(cpp, "native/a.c\nnative/b.cpp");
let csharp = run_find(serde_json::json!({ "language": "C#" }), ws.path()).await;
assert_eq!(csharp, "dotnet/App.cs");
}
#[tokio::test]
async fn find_output_is_sorted() {
let ws = tempfile::TempDir::new().unwrap();
for f in ["m.txt", "a.txt", "z.txt", "c.txt"] {
touch(ws.path(), f);
}
let out = run_find(serde_json::json!({ "name": "*.txt" }), ws.path()).await;
let lines: Vec<&str> = out.lines().collect();
assert_eq!(
lines,
vec!["a.txt", "c.txt", "m.txt", "z.txt"],
"got: {out}"
);
}
#[tokio::test]
async fn find_type_filter() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "pkg/file.rs");
std::fs::create_dir_all(ws.path().join("pkg/sub")).unwrap();
let dirs = run_find(serde_json::json!({ "type": "d" }), ws.path()).await;
assert!(
dirs.contains("pkg") && dirs.contains("pkg/sub"),
"got: {dirs}"
);
assert!(!dirs.contains("file.rs"), "dirs-only leaked a file: {dirs}");
let files = run_find(serde_json::json!({ "type": "f" }), ws.path()).await;
assert!(files.contains("pkg/file.rs"), "got: {files}");
assert!(
!files.lines().any(|l| l == "pkg" || l == "pkg/sub"),
"files-only leaked a dir: {files}"
);
}
#[tokio::test]
async fn find_gitignore_and_default_skips() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join(".gitignore"), "ignored.txt\n").unwrap();
touch(ws.path(), "kept.txt");
touch(ws.path(), "ignored.txt");
touch(ws.path(), "target/build_artifact.txt");
touch(ws.path(), "node_modules/dep.txt");
let on = run_find(serde_json::json!({ "name": "*.txt" }), ws.path()).await;
assert!(on.contains("kept.txt"), "got: {on}");
assert!(!on.contains("ignored.txt"), "gitignore not honoured: {on}");
assert!(!on.contains("target/"), "target not skipped: {on}");
assert!(
!on.contains("node_modules/"),
"node_modules not skipped: {on}"
);
let off = run_find(
serde_json::json!({ "name": "*.txt", "respect_gitignore": false }),
ws.path(),
)
.await;
assert!(off.contains("ignored.txt"), "opt-out should show it: {off}");
assert!(off.contains("target/build_artifact.txt"), "got: {off}");
}
#[tokio::test]
async fn find_max_results_caps_and_notes_truncation() {
let ws = tempfile::TempDir::new().unwrap();
for i in 0..10 {
touch(ws.path(), &format!("f{i}.txt"));
}
let out = run_find(
serde_json::json!({ "name": "*.txt", "max_results": 3 }),
ws.path(),
)
.await;
let body: Vec<&str> = out.lines().filter(|l| l.ends_with(".txt")).collect();
assert_eq!(body.len(), 3, "should cap at 3: {out}");
assert!(out.contains("truncated at 3"), "got: {out}");
}
#[tokio::test]
async fn find_missing_root_and_no_matches() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "a.txt");
let missing = run_find(serde_json::json!({ "path": "does/not/exist" }), ws.path()).await;
assert!(missing.starts_with("error:"), "got: {missing}");
let empty = run_find(serde_json::json!({ "name": "*.nope" }), ws.path()).await;
assert_eq!(empty, "no matches", "got: {empty}");
}
#[tokio::test]
async fn find_denied_without_fs_read() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "secret.txt");
let denied = Caveats {
fs_read: Scope::none(),
..caveats_rw(ws.path())
};
let out = run_tool(
"find",
serde_json::json!({ "name": "*" }),
ws.path(),
&denied,
None,
)
.await;
assert!(out.starts_with("capability denied"), "got: {out}");
let mut gate = MockGate::new(true, &denied);
let out = run_tool_gated(
"find",
serde_json::json!({"name": "secret.txt"}),
ws.path(),
&denied,
&mut gate,
)
.await;
assert_eq!(out, "secret.txt");
assert_eq!(
gate.asks.len(),
1,
"in-workspace authority remains grantable"
);
}
#[tokio::test]
async fn find_refuses_root_outside_workspace() {
let parent = tempfile::TempDir::new().unwrap();
std::fs::write(parent.path().join("outside.txt"), b"x").unwrap();
let ws = parent.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
for path in [
"..".to_string(),
parent.path().to_string_lossy().into_owned(),
] {
for fs_read in [Scope::All, Scope::none()] {
let caveats = Caveats {
fs_read,
..caveats_rw(&ws)
};
let mut gate = MockGate::new(true, &caveats);
let out = run_tool_gated(
"find",
serde_json::json!({"path": path}),
&ws,
&caveats,
&mut gate,
)
.await;
assert!(out.starts_with("capability denied"), "got: {out}");
assert!(out.contains("workspace-only"), "got: {out}");
assert!(!out.contains("request_permissions"), "got: {out}");
assert!(gate.asks.is_empty(), "an unsupported root must not prompt");
}
}
}
#[tokio::test]
async fn find_empty_name_matches_everything() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "a.txt");
touch(ws.path(), "sub/b.rs");
let out = run_find(serde_json::json!({ "name": "" }), ws.path()).await;
for expected in ["a.txt", "sub", "sub/b.rs"] {
assert!(
out.lines().any(|l| l == expected),
"empty name should match `{expected}`: {out}"
);
}
}
#[tokio::test]
async fn find_hidden_entries_gated_by_respect_gitignore() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "visible.txt");
touch(ws.path(), ".hidden.txt");
touch(ws.path(), ".config/secret.txt");
let default = run_find(serde_json::json!({ "name": "*" }), ws.path()).await;
assert!(
default.lines().any(|l| l == "visible.txt"),
"got: {default}"
);
assert!(
!default.contains(".hidden") && !default.contains(".config"),
"hidden entries must be skipped by default: {default}"
);
let all = run_find(
serde_json::json!({ "name": "*", "respect_gitignore": false }),
ws.path(),
)
.await;
assert!(all.contains(".hidden.txt"), "opt-out should show it: {all}");
assert!(all.contains(".config/secret.txt"), "got: {all}");
}
#[cfg(unix)]
#[tokio::test]
async fn find_does_not_follow_symlinks_out_of_workspace() {
let outside = tempfile::TempDir::new().unwrap();
std::fs::write(outside.path().join("secret.txt"), b"x").unwrap();
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "inside.txt");
std::os::unix::fs::symlink(outside.path(), ws.path().join("link")).unwrap();
let leaked = run_find(serde_json::json!({ "name": "secret.txt" }), ws.path()).await;
assert_eq!(
leaked, "no matches",
"symlink was followed out of ws: {leaked}"
);
let found = run_find(serde_json::json!({ "name": "inside.txt" }), ws.path()).await;
assert_eq!(found, "inside.txt", "got: {found}");
}
#[test]
fn glob_to_regex_anchors_and_escapes() {
let re = glob_to_regex("*.py", true).unwrap();
assert!(re.is_match("foo.py"));
assert!(!re.is_match("foo.pyc")); assert!(!re.is_match("fooxpy")); assert!(glob_to_regex("a?c", true).unwrap().is_match("abc"));
assert!(!glob_to_regex("a?c", true).unwrap().is_match("ac"));
assert!(glob_to_regex("readme.md", false)
.unwrap()
.is_match("README.MD"));
assert!(!glob_to_regex("readme.md", true)
.unwrap()
.is_match("README.MD"));
}