use super::{
ToolResult, ToolResultDisplay, ToolRuntime,
args::{
AST_GREP_DEFAULT_LIMIT, AST_GREP_STDERR_MAX_BYTES, AST_GREP_STDOUT_MAX_BYTES, AstGrepArgs,
},
contract::{metadata_key as meta, tool_name},
fs::ExistingPathPolicy,
process::{BoundedChildProcessLimits, run_bounded_child_process},
workspace::WorkspaceWalkOptions,
};
use crate::agent::cancellation::AgentCancellation;
use serde_json::json;
use std::{
path::{Path, PathBuf},
process::{Command, Stdio},
time::{Duration, Instant},
};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
const AST_GREP_TIMEOUT: Duration = Duration::from_secs(30);
const AST_GREP_MAX_BATCH_ARG_BYTES: usize = 24 * 1024;
const AST_GREP_MISSING: &str = "ast-grep is not installed. Install with: brew install ast-grep | cargo install ast-grep --locked | npm install --global @ast-grep/cli";
fn apply_ast_grep_env_allowlist(command: &mut Command) {
command.env_clear();
for (key, value) in std::env::vars_os() {
if allowed_ast_grep_env(&key) {
command.env(key, value);
}
}
}
fn allowed_ast_grep_env(key: &std::ffi::OsStr) -> bool {
let Some(key) = key.to_str() else {
return false;
};
matches!(key, "PATH" | "HOME" | "TMPDIR" | "LANG") || key.starts_with("LC_")
}
fn path_arg_bytes(path: &Path) -> usize {
path.as_os_str().as_encoded_bytes().len() + 1
}
fn batch_would_overflow(current_bytes: usize, path: &Path) -> bool {
current_bytes > 0
&& path_arg_bytes(path) > AST_GREP_MAX_BATCH_ARG_BYTES.saturating_sub(current_bytes)
}
#[cfg(test)]
mod batch_tests {
use super::*;
#[test]
fn argument_budget_starts_new_batch_before_overflow() {
let first = PathBuf::from("a".repeat(AST_GREP_MAX_BATCH_ARG_BYTES / 2));
let second = PathBuf::from("b".repeat(AST_GREP_MAX_BATCH_ARG_BYTES / 2));
assert!(!batch_would_overflow(0, &first));
assert!(batch_would_overflow(path_arg_bytes(&first), &second));
}
#[test]
fn oversized_single_path_still_makes_progress() {
let path = PathBuf::from("x".repeat(AST_GREP_MAX_BATCH_ARG_BYTES + 1));
assert!(!batch_would_overflow(0, &path));
}
}
#[derive(Default)]
struct AstGrepRunState {
stdout: String,
stderr: String,
exit_code: i32,
timed_out: bool,
cleanup_warning: Option<String>,
stdout_truncated: bool,
stderr_truncated: bool,
missing_binary: bool,
workset_truncated: bool,
}
struct AstGrepBatchContext<'a> {
runtime: &'a ToolRuntime,
bin: &'a Path,
args: &'a AstGrepArgs,
cancellation: &'a AgentCancellation,
started: Instant,
limit: usize,
}
fn run_ast_grep_batch(
context: &AstGrepBatchContext<'_>,
batch: &[PathBuf],
state: &mut AstGrepRunState,
) -> anyhow::Result<bool> {
context.cancellation.check()?;
let remaining = AST_GREP_TIMEOUT.saturating_sub(context.started.elapsed());
if remaining.is_zero() {
state.exit_code = -1;
state.timed_out = true;
return Ok(false);
}
let mut command = Command::new(context.bin);
command
.arg("run")
.arg("--pattern")
.arg(&context.args.pattern)
.arg("--heading=never");
if let Some(lang) = &context.args.language {
command.arg("--lang").arg(lang);
}
if let Some(rewrite) = &context.args.rewrite {
command.arg("--rewrite").arg(rewrite);
}
command.arg("--");
for path in batch {
command.arg(path);
}
command
.current_dir(&context.runtime.cwd)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
apply_ast_grep_env_allowlist(&mut command);
#[cfg(unix)]
command.process_group(0);
let child = match command.spawn() {
Ok(child) => child,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
state.missing_binary = true;
return Ok(false);
}
Err(error) => return Err(error.into()),
};
let output = run_bounded_child_process(
child,
BoundedChildProcessLimits {
stdout_max_bytes: AST_GREP_STDOUT_MAX_BYTES.saturating_sub(state.stdout.len()),
stderr_max_bytes: AST_GREP_STDERR_MAX_BYTES.saturating_sub(state.stderr.len()),
timeout: remaining,
poll_interval: Duration::from_millis(20),
},
context.cancellation,
)?;
let batch_lines = output.stdout.lines().count();
append_batch_output(&mut state.stdout, &output.stdout);
append_batch_output(&mut state.stderr, &output.stderr);
state.stdout_truncated |= output.stdout_truncated;
state.stderr_truncated |= output.stderr_truncated;
state.timed_out |= output.timed_out;
if state.cleanup_warning.is_none() {
state.cleanup_warning = output.cleanup_warning;
}
state.exit_code = output
.status
.as_ref()
.and_then(|status| status.code())
.unwrap_or(-1);
if state.exit_code == 1 && batch_lines == 0 && !state.stdout.is_empty() {
state.exit_code = 0;
}
Ok(!state.timed_out
&& !state.stdout_truncated
&& !state.stderr_truncated
&& state.cleanup_warning.is_none()
&& (state.exit_code == 0 || (state.exit_code == 1 && batch_lines == 0))
&& state.stdout.lines().count() < context.limit)
}
impl ToolRuntime {
pub(super) fn ast_grep(
&self,
args: AstGrepArgs,
cancellation: &AgentCancellation,
) -> anyhow::Result<ToolResult> {
self.ast_grep_with_bin(args, Path::new("ast-grep"), cancellation)
}
pub(super) fn ast_grep_with_bin(
&self,
args: AstGrepArgs,
bin: &Path,
cancellation: &AgentCancellation,
) -> anyhow::Result<ToolResult> {
let limit = args.limit.unwrap_or(AST_GREP_DEFAULT_LIMIT);
let path_arg = args.path.clone().unwrap_or_else(|| ".".to_string());
let resolved = self.resolve_existing_path(
&path_arg,
ExistingPathPolicy::ast_grep(self.ast_grep_absolute_paths),
)?;
cancellation.check()?;
let context = AstGrepBatchContext {
runtime: self,
bin,
args: &args,
cancellation,
started: Instant::now(),
limit,
};
let mut state = AstGrepRunState {
exit_code: 1,
..Default::default()
};
if resolved.is_dir() {
let mut batch = Vec::new();
let mut batch_bytes = 0;
let mut continue_search = true;
self.workspace_walker.visit_files(
WorkspaceWalkOptions {
root: &resolved,
include_files: true,
include_dirs: false,
skip_dirs: &[".git"],
cancel_interval: 32,
},
Some(cancellation),
|path| {
if context.started.elapsed() >= AST_GREP_TIMEOUT {
state.exit_code = -1;
state.timed_out = true;
continue_search = false;
return Ok(false);
}
if batch_would_overflow(batch_bytes, &path) {
continue_search = run_ast_grep_batch(&context, &batch, &mut state)?;
if !continue_search && state.stdout.lines().count() >= limit {
state.workset_truncated = true;
}
batch.clear();
batch_bytes = 0;
if !continue_search {
return Ok(false);
}
}
batch_bytes += path_arg_bytes(&path);
batch.push(path);
Ok(true)
},
)?;
if continue_search && !batch.is_empty() {
run_ast_grep_batch(&context, &batch, &mut state)?;
} else if batch.is_empty()
&& context.started.elapsed() >= AST_GREP_TIMEOUT
&& !state.timed_out
{
state.exit_code = -1;
state.timed_out = true;
}
} else {
run_ast_grep_batch(&context, &[resolved], &mut state)?;
}
if state.missing_binary {
return Ok(ast_grep_failure_result(AST_GREP_MISSING));
}
Ok(ast_grep_result(
AstGrepResultInput {
stdout: &state.stdout,
stderr: &state.stderr,
exit_code: state.exit_code,
timed_out: state.timed_out,
stdout_truncated: state.stdout_truncated,
stderr_truncated: state.stderr_truncated,
cleanup_warning: state.cleanup_warning,
workset_truncated: state.workset_truncated,
},
&args,
limit,
))
}
}
fn append_batch_output(aggregate: &mut String, batch: &str) {
if aggregate.is_empty() || batch.is_empty() {
aggregate.push_str(batch);
} else {
if !aggregate.ends_with('\n') {
aggregate.push('\n');
}
aggregate.push_str(batch);
}
}
#[derive(Default)]
struct AstGrepResultInput<'a> {
stdout: &'a str,
stderr: &'a str,
exit_code: i32,
timed_out: bool,
stdout_truncated: bool,
stderr_truncated: bool,
workset_truncated: bool,
cleanup_warning: Option<String>,
}
fn ast_grep_result(input: AstGrepResultInput<'_>, args: &AstGrepArgs, limit: usize) -> ToolResult {
let AstGrepResultInput {
stdout,
stderr,
exit_code,
timed_out,
stdout_truncated,
stderr_truncated,
workset_truncated,
cleanup_warning,
} = input;
let no_matches = exit_code == 1 && stdout.trim().is_empty();
let lines: Vec<&str> = stdout.lines().take(limit).collect();
let truncated = stdout.lines().count() > lines.len()
|| stdout_truncated
|| stderr_truncated
|| workset_truncated;
let success = !timed_out && cleanup_warning.is_none() && (exit_code == 0 || no_matches);
let mut metadata = json!({meta::ENGINE:"ast-grep", meta::EXIT_CODE:exit_code, meta::TIMED_OUT:timed_out, meta::MATCHES_RETURNED:lines.len(), meta::TRUNCATED:truncated, meta::STDOUT_TRUNCATED:stdout_truncated, meta::STDERR_TRUNCATED:stderr_truncated});
if let Some(lang) = &args.language {
metadata[meta::LANGUAGE] = json!(lang);
}
if args.rewrite.is_some() {
metadata[meta::REWRITE] = json!(true);
}
if let Some(warning) = cleanup_warning {
metadata[meta::CLEANUP_WARNING] = json!(warning);
}
ToolResult {
tool_name: tool_name::AST_GREP.to_string(),
success,
content: if !stderr.is_empty() && !success && !no_matches {
format!("{stdout}{stderr}")
} else {
lines.join("\n")
},
metadata,
display: ToolResultDisplay::default(),
}
}
fn ast_grep_failure_result(message: &str) -> ToolResult {
ToolResult {
tool_name: tool_name::AST_GREP.to_string(),
success: false,
content: message.to_string(),
metadata: json!({
meta::ENGINE: "ast-grep",
}),
display: ToolResultDisplay::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::cancellation::AgentCancellation;
use serde_json::json;
use std::{
fs,
path::Path,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
time::Duration,
};
use tempfile::TempDir;
#[cfg(unix)]
fn make_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).unwrap();
}
fn runtime() -> (TempDir, ToolRuntime) {
let temp = TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
(temp, runtime)
}
#[test]
fn rejects_empty_pattern() {
let (_temp, runtime) = runtime();
let result = runtime.dispatch("ast_grep", json!({"pattern": " "}));
assert!(!result.success);
assert!(result.content.contains("pattern must not be empty"));
}
#[test]
fn rejects_zero_limit() {
let (_temp, runtime) = runtime();
let result = runtime.dispatch("ast_grep", json!({"pattern": "test", "limit": 0}));
assert!(!result.success);
assert!(result.content.contains("limit must be at least 1"));
}
#[test]
fn rejects_excessive_limit() {
let (_temp, runtime) = runtime();
let result = runtime.dispatch("ast_grep", json!({"pattern": "test", "limit": 501}));
assert!(!result.success);
assert!(result.content.contains("limit must be at most"));
}
#[test]
fn rejects_unknown_field() {
let (_temp, runtime) = runtime();
let result = runtime.dispatch("ast_grep", json!({"pattern": "test", "bogus": true}));
assert!(!result.success);
}
#[cfg(unix)]
#[test]
fn missing_binary_returns_install_message() {
let (temp, runtime) = runtime();
fs::write(temp.path().join("visible.rs"), "test").unwrap();
let result = runtime
.ast_grep_with_bin(
AstGrepArgs {
pattern: "test".to_string(),
language: None,
path: Some(".".to_string()),
rewrite: None,
limit: None,
},
Path::new("/nonexistent/ast-grep-binary-12345"),
&AgentCancellation::default(),
)
.unwrap();
assert!(!result.success);
assert!(result.content.contains("ast-grep is not installed"));
assert_eq!(result.metadata[meta::ENGINE], "ast-grep");
let _ = temp;
}
#[cfg(unix)]
#[test]
fn fake_binary_returns_matches() {
let (temp, runtime) = runtime();
let fake = temp.path().join("fake-ast-grep");
fs::write(
&fake,
"#!/bin/sh\necho 'src/main.rs:5:unwrap()'\necho 'src/lib.rs:12:unwrap()'\nexit 0\n",
)
.unwrap();
make_executable(&fake);
let result = runtime
.ast_grep_with_bin(
AstGrepArgs {
pattern: "unwrap()".to_string(),
language: Some("rust".to_string()),
path: Some(".".to_string()),
rewrite: None,
limit: Some(10),
},
&fake,
&AgentCancellation::default(),
)
.unwrap();
assert!(result.success, "{}", result.content);
assert!(result.content.contains("src/main.rs:5:unwrap()"));
assert!(result.content.contains("src/lib.rs:12:unwrap()"));
assert_eq!(result.metadata[meta::ENGINE], "ast-grep");
assert_eq!(result.metadata[meta::EXIT_CODE], 0);
assert_eq!(result.metadata[meta::MATCHES_RETURNED], 2);
assert_eq!(result.metadata[meta::TRUNCATED], false);
assert_eq!(result.metadata[meta::LANGUAGE], "rust");
}
#[cfg(unix)]
#[test]
fn directory_walk_passes_only_visible_files_after_separator() {
let (temp, runtime) = runtime();
fs::write(temp.path().join(".p4ignore"), "ignored.rs\n").unwrap();
fs::write(temp.path().join("ignored.rs"), "hidden").unwrap();
fs::write(temp.path().join("visible.rs"), "shown").unwrap();
let fake = temp.path().join("fake-ast-grep");
fs::write(
&fake,
"#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$0.args\"\necho match\n",
)
.unwrap();
make_executable(&fake);
let result = runtime
.ast_grep_with_bin(
AstGrepArgs {
pattern: "x".into(),
language: None,
path: Some(".".into()),
rewrite: Some("y".into()),
limit: None,
},
&fake,
&AgentCancellation::default(),
)
.unwrap();
assert!(result.success);
let argv = fs::read_to_string(format!("{}.args", fake.display())).unwrap();
assert!(argv.contains("visible.rs"));
assert!(!argv.contains("ignored.rs"));
assert!(argv.contains("--rewrite"));
}
#[cfg(unix)]
#[test]
fn exact_limit_stops_before_later_argument_batch() {
let (temp, runtime) = runtime();
for index in 0..140 {
fs::write(
temp.path()
.join(format!("{index:03}-{}.rs", "x".repeat(190))),
"match",
)
.unwrap();
}
let fake = temp.path().join("fake-ast-grep");
fs::write(&fake, "#!/bin/sh\necho call >> \"$0.calls\"\necho match\n").unwrap();
make_executable(&fake);
let result = runtime
.ast_grep_with_bin(
AstGrepArgs {
pattern: "match".into(),
language: None,
path: Some(".".into()),
rewrite: None,
limit: Some(1),
},
&fake,
&AgentCancellation::default(),
)
.unwrap();
assert!(result.success, "{}", result.content);
assert_eq!(result.metadata[meta::MATCHES_RETURNED], 1);
assert_eq!(result.metadata[meta::TRUNCATED], true);
let calls = fs::read_to_string(format!("{}.calls", fake.display())).unwrap();
assert_eq!(calls.lines().count(), 1);
}
#[test]
fn empty_directory_workset_is_successful_no_match() {
let (_temp, runtime) = runtime();
let result = runtime.dispatch("ast_grep", json!({"pattern": "x"}));
assert!(result.success);
assert_eq!(result.content, "");
assert_eq!(result.metadata[meta::MATCHES_RETURNED], 0);
}
#[cfg(unix)]
#[test]
fn fake_binary_does_not_inherit_credential_env() {
let (temp, runtime) = runtime();
let fake = temp.path().join("fake-ast-grep-env");
fs::write(
&fake,
"#!/bin/sh\nprintf '%s' \"$MAGI_CODE_TEST_ENV_PROBE_AST\"\n",
)
.unwrap();
make_executable(&fake);
unsafe { std::env::set_var("MAGI_CODE_TEST_ENV_PROBE_AST", "leaked") };
let result = runtime
.ast_grep_with_bin(
AstGrepArgs {
pattern: "test".to_string(),
language: None,
path: Some(".".to_string()),
rewrite: None,
limit: None,
},
&fake,
&AgentCancellation::default(),
)
.unwrap();
unsafe { std::env::remove_var("MAGI_CODE_TEST_ENV_PROBE_AST") };
assert!(result.success, "{}", result.content);
assert_eq!(result.content, "");
}
#[cfg(unix)]
#[test]
fn no_matches_exit_one_is_success() {
let (temp, runtime) = runtime();
let fake = temp.path().join("fake-ast-grep");
fs::write(&fake, "#!/bin/sh\nexit 1\n").unwrap();
make_executable(&fake);
let result = runtime
.ast_grep_with_bin(
AstGrepArgs {
pattern: "nomatch".to_string(),
language: None,
path: Some(".".to_string()),
rewrite: None,
limit: None,
},
&fake,
&AgentCancellation::default(),
)
.unwrap();
assert!(result.success, "no matches should not be failure");
assert_eq!(result.content, "");
assert_eq!(result.metadata[meta::EXIT_CODE], 1);
assert_eq!(result.metadata[meta::MATCHES_RETURNED], 0);
}
#[cfg(unix)]
#[test]
fn limit_caps_returned_lines_and_sets_truncated() {
let (temp, runtime) = runtime();
let fake = temp.path().join("fake-ast-grep");
let lines: Vec<String> = (0..10).map(|i| format!("file:{i}:match")).collect();
fs::write(
&fake,
format!("#!/bin/sh\necho '{}'\nexit 0\n", lines.join("\\n")),
)
.unwrap();
make_executable(&fake);
let result = runtime
.ast_grep_with_bin(
AstGrepArgs {
pattern: "match".to_string(),
language: None,
path: Some(".".to_string()),
rewrite: None,
limit: Some(3),
},
&fake,
&AgentCancellation::default(),
)
.unwrap();
assert!(result.success, "{}", result.content);
assert_eq!(result.metadata[meta::MATCHES_RETURNED], 3);
assert_eq!(result.metadata[meta::TRUNCATED], true);
}
#[cfg(unix)]
#[test]
fn timeout_produces_failure() {
let (temp, runtime) = runtime();
let fake = temp.path().join("fake-ast-grep-sleep");
fs::write(&fake, "#!/bin/sh\necho 'match'\nsleep 60\n").unwrap();
make_executable(&fake);
let result = runtime
.ast_grep_with_bin(
AstGrepArgs {
pattern: "match".to_string(),
language: None,
path: Some(".".to_string()),
rewrite: None,
limit: None,
},
&fake,
&AgentCancellation::default(),
)
.unwrap();
assert!(!result.success);
assert_eq!(result.metadata[meta::TIMED_OUT], true);
}
#[cfg(unix)]
#[test]
fn cancellation_aborts_promptly() {
let (temp, runtime) = runtime();
let fake = temp.path().join("fake-ast-grep-sleep");
fs::write(&fake, "#!/bin/sh\nsleep 60\n").unwrap();
make_executable(&fake);
let cancel_flag = Arc::new(AtomicBool::new(false));
let cancellation = AgentCancellation::new(Arc::clone(&cancel_flag));
thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
cancel_flag.store(true, Ordering::SeqCst);
});
let result = runtime.ast_grep_with_bin(
AstGrepArgs {
pattern: "match".to_string(),
language: None,
path: Some(".".to_string()),
rewrite: None,
limit: None,
},
&fake,
&cancellation,
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("prompt canceled"));
}
#[cfg(unix)]
#[test]
fn stderr_failure_included_in_content() {
let (temp, runtime) = runtime();
let fake = temp.path().join("fake-ast-grep-err");
fs::write(&fake, "#!/bin/sh\necho 'bad pattern' >&2\nexit 2\n").unwrap();
make_executable(&fake);
let result = runtime
.ast_grep_with_bin(
AstGrepArgs {
pattern: "test".to_string(),
language: None,
path: Some(".".to_string()),
rewrite: None,
limit: None,
},
&fake,
&AgentCancellation::default(),
)
.unwrap();
assert!(!result.success);
assert!(result.content.contains("bad pattern"));
assert_eq!(result.metadata[meta::EXIT_CODE], 2);
}
#[test]
fn schema_registered_in_provider_definitions() {
let definitions = crate::tools::capability::mvp_tool_definitions_json();
let ast_grep = definitions
.as_array()
.unwrap()
.iter()
.find(|d| d["name"] == "ast_grep")
.expect("ast_grep definition must exist");
let params = &ast_grep["parameters"];
assert_eq!(params["required"], json!(["pattern"]));
assert_eq!(params["additionalProperties"], false);
assert!(params["properties"].get("pattern").is_some());
assert!(params["properties"].get("language").is_some());
assert!(params["properties"].get("path").is_some());
assert!(params["properties"].get("rewrite").is_some());
assert!(params["properties"].get("limit").is_some());
}
#[test]
fn empty_rewrite_ignored_not_error() {
let (_temp, runtime) = runtime();
let result = runtime.dispatch("ast_grep", json!({"pattern": "test", "rewrite": " "}));
assert!(
!result.content.contains("rewrite must not be empty"),
"empty rewrite should be ignored, not error: {}",
result.content
);
}
#[test]
fn dispatch_name_resolves_to_capability() {
assert_eq!(
crate::tools::ToolCapability::from_dispatch_name("ast_grep"),
Some(crate::tools::ToolCapability::AstGrep)
);
}
}