fix: allow Grep to target a file

This commit is contained in:
2026-08-30 19:50:57 +09:00
parent a7bf5ceac3
commit 80c1f48f0e
5 changed files with 191 additions and 8 deletions
+147
View File
@@ -280,6 +280,153 @@ mod tests {
assert!(!grep.output.contains("c.txt")); assert!(!grep.output.contains("c.txt"));
} }
#[test]
fn grep_accepts_a_direct_file_without_searching_siblings() {
let temp = tempfile::tempdir().unwrap();
let selected = temp.path().join("selected.txt");
std::fs::write(&selected, "before\nneedle selected\nafter\n").unwrap();
std::fs::write(temp.path().join("sibling.txt"), "needle sibling\n").unwrap();
let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone());
let direct = run_grep(
&root,
selected,
GrepRequest {
pattern: "needle".to_string(),
path: FsPath::new("selected.txt").unwrap(),
glob: None,
file_type: None,
case_insensitive: false,
before_context: 1,
after_context: 1,
multiline: false,
output_mode: GrepOutputMode::Content,
limit: 10,
offset: 0,
},
&readable,
)
.unwrap();
assert_eq!(direct.match_count, 1);
assert_eq!(direct.matched_files, 1);
assert_eq!(
direct.output,
concat!(
"selected.txt\n",
" 1 │ before\n",
" > 2 │ needle selected\n",
" 3 │ after\n",
)
);
assert!(!direct.output.contains("sibling"));
let directory = run_grep(
&root,
root.clone(),
GrepRequest {
pattern: "needle".to_string(),
path: FsPath::root(),
glob: None,
file_type: None,
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: GrepOutputMode::Content,
limit: 10,
offset: 0,
},
&readable,
)
.unwrap();
assert_eq!(directory.match_count, 2);
assert_eq!(directory.matched_files, 2);
}
#[test]
fn grep_returns_not_found_for_a_missing_direct_path() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
let missing = root.join("missing.txt");
let readable = RootAccess(root.clone());
let error = run_grep(
&root,
missing.clone(),
GrepRequest {
pattern: "needle".to_string(),
path: FsPath::new("missing.txt").unwrap(),
glob: None,
file_type: None,
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: GrepOutputMode::Content,
limit: 10,
offset: 0,
},
&readable,
)
.unwrap_err();
assert!(matches!(error, FsError::NotFound(path) if path == missing));
}
#[cfg(unix)]
#[test]
fn grep_keeps_direct_symlink_directory_and_broken_path_guards() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone());
std::fs::create_dir(root.join("target-dir")).unwrap();
symlink(root.join("target-dir"), root.join("directory-link")).unwrap();
symlink(root.join("missing-target"), root.join("broken-link")).unwrap();
let request = |path: &str| GrepRequest {
pattern: "needle".to_string(),
path: FsPath::new(path).unwrap(),
glob: None,
file_type: None,
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: GrepOutputMode::Content,
limit: 10,
offset: 0,
};
let directory_error = run_grep(
&root,
root.join("directory-link"),
request("directory-link"),
&readable,
)
.unwrap_err();
assert!(matches!(
directory_error,
FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path, .. }
if path == root.join("directory-link")
));
let broken_error = run_grep(
&root,
root.join("broken-link"),
request("broken-link"),
&readable,
)
.unwrap_err();
assert!(matches!(
broken_error,
FsError::BrokenSymlink { path, .. } if path == root.join("broken-link")
));
}
#[test] #[test]
fn grep_content_groups_lines_by_file_and_marks_matches() { fn grep_content_groups_lines_by_file_and_marks_matches() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
+5 -3
View File
@@ -221,13 +221,15 @@ pub fn run_grep(
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()), std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
_ => FsError::io(&base, e), _ => FsError::io(&base, e),
})?; })?;
if !base_meta.is_dir() { if !base_meta.is_file() && !base_meta.is_dir() {
return Err(FsError::InvalidArgument(format!( return Err(FsError::InvalidArgument(format!(
"grep search path is not a directory: {}", "grep search path must be a regular file or directory: {}",
base.display() base.display()
))); )));
} }
if let Some(info) = symlink.as_ref() { if base_meta.is_dir()
&& let Some(info) = symlink.as_ref()
{
return Err(FsError::SymlinkDirectoryNotTraversed { return Err(FsError::SymlinkDirectoryNotTraversed {
tool: "Grep", tool: "Grep",
path: base.clone(), path: base.clone(),
+2 -2
View File
@@ -22,7 +22,7 @@ enum OutputMode {
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
struct GrepParams { struct GrepParams {
pattern: String, pattern: String,
/// Logical Workdir-relative path to search. Defaults to the Workdir root. /// Logical Workdir-relative file or directory to search. Defaults to the Workdir root.
#[serde(default)] #[serde(default)]
path: Option<String>, path: Option<String>,
#[serde(default)] #[serde(default)]
@@ -129,7 +129,7 @@ pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
Arc::new(move || { Arc::new(move || {
let schema = schemars::schema_for!(GrepParams); let schema = schemars::schema_for!(GrepParams);
let meta = ToolMeta::new("Grep") let meta = ToolMeta::new("Grep")
.description("Search Workdir file contents with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.") .description("Search a Workdir file or directory with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Directory traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization")); .input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
let tool: Arc<dyn Tool> = Arc::new(GrepTool { let tool: Arc<dyn Tool> = Arc::new(GrepTool {
session: session.clone(), session: session.clone(),
+1 -1
View File
@@ -1943,7 +1943,7 @@ mod tests {
&workdir, &workdir,
GrepRequest { GrepRequest {
pattern: "NEEDLE".into(), pattern: "NEEDLE".into(),
path: WorkdirPath::root(), path: WorkdirPath::new("src/main.rs").unwrap(),
glob: None, glob: None,
file_type: None, file_type: None,
case_insensitive: false, case_insensitive: false,
+36 -2
View File
@@ -1886,8 +1886,8 @@ mod tests {
use manifest::{Scope, SharedScope}; use manifest::{Scope, SharedScope};
use tower::ServiceExt; use tower::ServiceExt;
use workdir::{ use workdir::{
LocalWorkdirSession, ReadRequest, StatRequest, Workdir, WorkdirPath, GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, StatRequest, Workdir,
WorkdirSessionCapabilities, WorkdirPath, WorkdirSessionCapabilities,
}; };
fn test_bundle(profile: ProfileSelector) -> ConfigBundle { fn test_bundle(profile: ProfileSelector) -> ConfigBundle {
@@ -2348,6 +2348,40 @@ mod tests {
.expect("owned operation"); .expect("owned operation");
assert!(matches!(result, WorkdirSessionOperationResult::Stat(_))); assert!(matches!(result, WorkdirSessionOperationResult::Stat(_)));
let grep = WorkdirSessionOperationRequest {
delegations: Vec::new(),
operation: WorkdirSessionOperation::Grep(GrepRequest {
pattern: "hello".into(),
path: WorkdirPath::new("hello.txt").unwrap(),
glob: None,
file_type: None,
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: GrepOutputMode::Content,
limit: 10,
offset: 0,
}),
};
let Json(result) = run_workdir_session_operation(
State(state.clone()),
Path("session-1".to_string()),
Some(Extension(auth.clone())),
Ok(Json(grep)),
)
.await
.expect("grep direct file through provider operation");
match result {
WorkdirSessionOperationResult::Grep(result) => {
assert_eq!(result.match_count, 1);
assert_eq!(result.matched_files, 1);
assert!(result.output.starts_with("hello.txt\n"));
assert!(result.output.contains("> 1 │ hello"));
}
other => panic!("unexpected workdir grep result: {other:?}"),
}
#[cfg(unix)] #[cfg(unix)]
{ {
let delegated_visible = WorkdirSessionOperationRequest { let delegated_visible = WorkdirSessionOperationRequest {