1use super::path_security::PathGuard;
31use super::{AgentTool, AgentToolResult, ToolContext, ToolError, ToolExecutionMode};
32use async_trait::async_trait;
33use serde_json::{Value, json};
34use std::path::{Path, PathBuf};
35use std::process::Stdio;
36use tokio::io::AsyncReadExt;
37use tokio::process::Command;
38use tokio::sync::oneshot;
39
40const MAX_PATHS_PER_INVOCATION: usize = 512;
43
44const DRY_RUN_STDOUT_CAP: usize = 8 * 1024 * 1024;
47
48#[derive(Debug, Clone)]
50struct RewriteOp {
51 pat: String,
52 out: String,
53}
54
55pub struct AstEditTool {
57 root_dir: Option<PathBuf>,
58}
59
60impl AstEditTool {
61 pub fn new() -> Self {
63 Self { root_dir: None }
64 }
65
66 pub fn with_cwd(cwd: PathBuf) -> Self {
68 Self {
69 root_dir: Some(cwd),
70 }
71 }
72
73 fn resolve_one(raw: &str, root: &Path) -> PathBuf {
76 let candidate = PathBuf::from(raw);
77 if candidate.is_absolute() {
78 candidate
79 } else {
80 root.join(candidate)
81 }
82 }
83
84 fn looks_like_glob(raw: &str) -> bool {
86 raw.contains('*') || raw.contains('?') || raw.contains('[')
87 }
88
89 fn expand_paths(raw_paths: &[String], root: &Path) -> Result<Vec<PathBuf>, ToolError> {
97 let mut out: Vec<PathBuf> = Vec::new();
98 let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
99
100 for raw in raw_paths {
101 if Self::looks_like_glob(raw) {
102 let candidate = Self::resolve_one(raw, root);
103 let pattern_str = candidate.to_string_lossy().into_owned();
104 let entries = glob::glob(&pattern_str)
105 .map_err(|e| format!("Invalid glob pattern '{}': {}", raw, e))?;
106 let mut matched_any = false;
107 for entry in entries {
108 let p = entry.map_err(|e| format!("Glob error for '{}': {}", raw, e))?;
109 matched_any = true;
110 if p.is_dir() {
114 if seen.insert(p.clone()) {
115 out.push(p);
116 }
117 } else if p.is_file() && seen.insert(p.clone()) {
118 out.push(p);
119 }
120 }
121 if !matched_any {
122 return Err(format!("Glob '{}' matched no files", raw));
123 }
124 } else {
125 let candidate = Self::resolve_one(raw, root);
126 if !candidate.exists() {
127 return Err(format!("Path not found: {}", raw));
128 }
129 if seen.insert(candidate.clone()) {
130 out.push(candidate);
131 }
132 }
133 }
134
135 if out.is_empty() {
136 return Err("No files matched the supplied paths/globs".to_string());
137 }
138
139 Ok(out)
140 }
141}
142
143impl Default for AstEditTool {
144 fn default() -> Self {
145 Self::new()
146 }
147}
148
149fn parse_ops(params: &Value) -> Result<Vec<RewriteOp>, ToolError> {
151 let arr = params
152 .get("ops")
153 .and_then(Value::as_array)
154 .ok_or_else(|| "Missing required parameter: ops (must be an array)".to_string())?;
155
156 if arr.is_empty() {
157 return Err("Parameter 'ops' must contain at least one { pat, out } entry".to_string());
158 }
159
160 let mut ops = Vec::with_capacity(arr.len());
161 for (i, op) in arr.iter().enumerate() {
162 let pat = op
163 .get("pat")
164 .and_then(Value::as_str)
165 .ok_or_else(|| format!("ops[{}]: missing or non-string 'pat'", i))?
166 .to_string();
167 let out = op
168 .get("out")
169 .and_then(Value::as_str)
170 .ok_or_else(|| format!("ops[{}]: missing or non-string 'out'", i))?
171 .to_string();
172
173 if pat.trim().is_empty() {
174 return Err(format!("ops[{}]: 'pat' must be a non-empty string", i));
175 }
176
177 ops.push(RewriteOp { pat, out });
178 }
179
180 Ok(ops)
181}
182
183fn parse_paths(params: &Value) -> Result<Vec<String>, ToolError> {
185 let arr = params
186 .get("paths")
187 .and_then(Value::as_array)
188 .ok_or_else(|| "Missing required parameter: paths (must be an array)".to_string())?;
189
190 if arr.is_empty() {
191 return Err("Parameter 'paths' must contain at least one path".to_string());
192 }
193
194 let mut paths = Vec::with_capacity(arr.len());
195 for (i, p) in arr.iter().enumerate() {
196 let s = p
197 .as_str()
198 .ok_or_else(|| format!("paths[{}]: must be a string", i))?;
199 paths.push(s.to_string());
200 }
201
202 Ok(paths)
203}
204
205async fn run_sg_for_op(
212 op: &RewriteOp,
213 paths: &[PathBuf],
214 dry_run: bool,
215) -> Result<(std::process::ExitStatus, Vec<u8>, Vec<u8>), String> {
216 let mut cmd = Command::new("sg");
217 cmd.arg("-p").arg(&op.pat).arg("-r").arg(&op.out);
218
219 if dry_run {
220 cmd.arg("--json=stream");
222 } else {
223 cmd.arg("-U");
226 }
227
228 for p in paths {
229 cmd.arg(p);
230 }
231
232 cmd.stdin(Stdio::null())
233 .stdout(Stdio::piped())
234 .stderr(Stdio::piped());
235
236 let mut child = match cmd.spawn() {
237 Ok(c) => c,
238 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
239 return Err(
240 "`sg` (ast-grep CLI) is not installed or not on PATH. Install it from https://ast-grep.github.io/ to use the ast_edit tool."
241 .to_string(),
242 );
243 }
244 Err(e) => return Err(format!("Failed to invoke `sg`: {e}")),
245 };
246
247 #[allow(clippy::expect_used)]
251 let mut stdout = child.stdout.take().expect("piped stdout");
252 #[allow(clippy::expect_used)]
253 let mut stderr = child.stderr.take().expect("piped stderr");
254
255 let mut stdout_buf = Vec::new();
256 let mut stderr_buf = Vec::new();
257
258 if dry_run {
259 let mut limited = stdout.take(DRY_RUN_STDOUT_CAP as u64);
261 let (s_res, e_res) = tokio::join!(
262 AsyncReadExt::read_to_end(&mut limited, &mut stdout_buf),
263 stderr.read_to_end(&mut stderr_buf)
264 );
265 s_res.map_err(|e| format!("Failed reading `sg` stdout: {e}"))?;
266 e_res.map_err(|e| format!("Failed reading `sg` stderr: {e}"))?;
267 } else {
268 let (s_res, e_res) = tokio::join!(
269 stdout.read_to_end(&mut stdout_buf),
270 stderr.read_to_end(&mut stderr_buf)
271 );
272 s_res.map_err(|e| format!("Failed reading `sg` stdout: {e}"))?;
273 e_res.map_err(|e| format!("Failed reading `sg` stderr: {e}"))?;
274 }
275
276 let status = child
277 .wait()
278 .await
279 .map_err(|e| format!("Failed waiting on `sg`: {e}"))?;
280
281 Ok((status, stdout_buf, stderr_buf))
282}
283
284fn summarise_dry_run(stdout: &[u8]) -> (usize, std::collections::BTreeMap<PathBuf, usize>) {
290 let mut total = 0usize;
291 let mut by_file: std::collections::BTreeMap<PathBuf, usize> = std::collections::BTreeMap::new();
292
293 for line in stdout.split(|b| *b == b'\n') {
294 let trimmed: Vec<u8> = line
295 .iter()
296 .copied()
297 .skip_while(|b| b.is_ascii_whitespace())
298 .take_while(|b| !b.is_ascii_whitespace())
299 .collect();
300 if trimmed.is_empty() {
301 continue;
302 }
303 if let Ok(v) = serde_json::from_slice::<Value>(&trimmed) {
304 if let Some(file) = v.get("file").and_then(Value::as_str) {
305 *by_file.entry(PathBuf::from(file)).or_insert(0) += 1;
306 }
307 total += 1;
308 }
309 }
310
311 (total, by_file)
312}
313
314fn parse_applied_count(stderr: &[u8]) -> Option<usize> {
316 let text = String::from_utf8_lossy(stderr);
317 for line in text.lines() {
318 let trimmed = line.trim();
319 if let Some(rest) = trimmed.strip_prefix("Applied ") {
320 let num = rest.split_whitespace().next()?;
321 return num.parse::<usize>().ok();
322 }
323 }
324 None
325}
326
327fn chunk_paths(paths: Vec<PathBuf>) -> Vec<Vec<PathBuf>> {
329 if paths.len() <= MAX_PATHS_PER_INVOCATION {
330 return vec![paths];
331 }
332 paths
333 .chunks(MAX_PATHS_PER_INVOCATION)
334 .map(|c| c.to_vec())
335 .collect()
336}
337
338#[async_trait]
339impl AgentTool for AstEditTool {
340 fn name(&self) -> &str {
341 "ast_edit"
342 }
343
344 fn label(&self) -> &str {
345 "AST Edit"
346 }
347
348 fn description(&self) -> &str {
349 "AST-aware structural code rewriting using ast-grep. Provide an `ops` array of `{pat, out}` pattern→replacement pairs and `paths` to files/dirs/globs to apply them to. Pattern and replacement use ast-grep syntax (e.g. pat='fn $NAME() -> i32 { $BODY }', out='fn $NAME() -> i64 { $BODY }'). Set `dry_run=true` (default) to preview matches without writing; set `dry_run=false` to apply in place. Requires the `sg` CLI on PATH. Globs are expanded by this tool before invoking ast-grep because ast-grep does not expand globs in positional path arguments."
350 }
351
352 fn parameters_schema(&self) -> Value {
353 json!({
354 "type": "object",
355 "properties": {
356 "ops": {
357 "type": "array",
358 "description": "Rewrite operations. Each entry maps an ast-grep pattern (`pat`) to a replacement template (`out`). Metavariables in `pat` (e.g. `$NAME`, `$BODY`) are interpolated into `out` by ast-grep.",
359 "items": {
360 "type": "object",
361 "properties": {
362 "pat": {
363 "type": "string",
364 "description": "AST pattern in ast-grep syntax (e.g. 'fn $NAME() -> i32 { $BODY }')."
365 },
366 "out": {
367 "type": "string",
368 "description": "Replacement template (e.g. 'fn $NAME() -> i64 { $BODY }')."
369 }
370 },
371 "required": ["pat", "out"],
372 "additionalProperties": false
373 },
374 "minItems": 1
375 },
376 "paths": {
377 "type": "array",
378 "description": "Files, directories, or globs to rewrite. Globs (containing `*`, `?`, or `[`) are expanded in-process before invoking ast-grep because ast-grep does not expand globs in positional path arguments.",
379 "items": { "type": "string" },
380 "minItems": 1
381 },
382 "dry_run": {
383 "type": "boolean",
384 "description": "When true (default), only preview matches — no files are modified. When false, apply the rewrites in place using ast-grep's `--update-all`.",
385 "default": true
386 }
387 },
388 "required": ["ops", "paths"]
389 })
390 }
391
392 fn execution_mode(&self) -> ToolExecutionMode {
393 ToolExecutionMode::SequentialOnly
401 }
402
403 fn intent(&self) -> Option<&str> {
404 Some("Applying AST rewrites")
405 }
406
407 async fn execute(
408 &self,
409 _tool_call_id: &str,
410 params: Value,
411 _signal: Option<oneshot::Receiver<()>>,
412 ctx: &ToolContext,
413 ) -> Result<AgentToolResult, ToolError> {
414 let ops = parse_ops(¶ms)?;
416 let raw_paths = parse_paths(¶ms)?;
417
418 let dry_run = params
420 .get("dry_run")
421 .and_then(Value::as_bool)
422 .unwrap_or(true);
423
424 let root = self.root_dir.as_deref().unwrap_or_else(|| ctx.root());
426 let guard = PathGuard::new(root);
427
428 let expanded = Self::expand_paths(&raw_paths, root)?;
429 for p in &expanded {
430 guard
431 .validate(p)
432 .map_err(|e| format!("Path '{}' rejected: {}", p.display(), e))?;
433 }
434
435 let mut total_replacements: usize = 0;
437 let mut total_files_touched: std::collections::BTreeSet<PathBuf> =
438 std::collections::BTreeSet::new();
439 let mut per_op_summary: Vec<String> = Vec::with_capacity(ops.len());
440 let mut had_error = false;
441 let mut error_messages: Vec<String> = Vec::new();
442
443 let expanded_count = expanded.len();
444
445 let chunks = chunk_paths(expanded);
448
449 for (op_idx, op) in ops.iter().enumerate() {
450 let mut op_count: usize = 0;
451 let mut op_files: std::collections::BTreeSet<PathBuf> =
452 std::collections::BTreeSet::new();
453
454 for chunk in &chunks {
455 match run_sg_for_op(op, chunk, dry_run).await {
456 Ok((status, stdout, stderr)) => {
457 if !status.success() {
458 let stderr_text = String::from_utf8_lossy(&stderr).trim().to_string();
459 let cleaned: String = stderr_text
462 .lines()
463 .filter(|l| {
464 !l.contains("`sg` is deprecated")
465 && !l.contains("Use `ast-grep` instead")
466 && !l.starts_with("======")
467 && !l.trim().is_empty()
468 })
469 .collect::<Vec<_>>()
470 .join(" ");
471
472 let stdout_text = String::from_utf8_lossy(&stdout).trim().to_string();
473
474 if !cleaned.is_empty() {
475 had_error = true;
476 error_messages.push(format!(
477 "ops[{}] (pat='{}') failed (exit {:?}): {}",
478 op_idx,
479 op.pat,
480 status.code(),
481 cleaned
482 ));
483 } else if !stdout_text.is_empty() {
484 had_error = true;
485 error_messages.push(format!(
486 "ops[{}] (pat='{}') failed (exit {:?}): {}",
487 op_idx,
488 op.pat,
489 status.code(),
490 stdout_text
491 ));
492 }
493 continue;
496 }
497
498 if dry_run {
499 let (count, by_file) = summarise_dry_run(&stdout);
500 op_count += count;
501 for (f, _n) in by_file {
502 op_files.insert(f);
503 }
504 } else if let Some(n) = parse_applied_count(&stderr) {
505 op_count += n;
506 for p in chunk {
509 op_files.insert(p.clone());
510 }
511 } else {
512 for p in chunk {
515 op_files.insert(p.clone());
516 }
517 error_messages.push(format!(
518 "ops[{}] (pat='{}'): apply succeeded but could not parse 'Applied N changes' from stderr",
519 op_idx, op.pat
520 ));
521 }
522 }
523 Err(msg) => {
524 had_error = true;
525 error_messages.push(format!("ops[{}]: {}", op_idx, msg));
526 }
527 }
528 }
529
530 total_replacements += op_count;
531 for f in &op_files {
532 total_files_touched.insert(f.clone());
533 }
534
535 let files_label = if op_files.is_empty() {
536 "0 files".to_string()
537 } else {
538 format!("{} location(s)", op_files.len())
539 };
540
541 let summary_line = if dry_run {
542 format!(
543 "ops[{}] pat='{}': {} match(es) across {}",
544 op_idx, op.pat, op_count, files_label
545 )
546 } else {
547 format!(
548 "ops[{}] pat='{}' → out='{}': {} replacement(s) across {}",
549 op_idx, op.pat, op.out, op_count, files_label
550 )
551 };
552 per_op_summary.push(summary_line);
553 }
554
555 let header = if dry_run {
557 "AST edit preview (dry-run) — no files modified"
558 } else {
559 "AST edit applied"
560 };
561
562 let mut body = String::new();
563 body.push_str(header);
564 body.push('\n');
565 body.push('\n');
566 for line in &per_op_summary {
567 body.push_str(line);
568 body.push('\n');
569 }
570 body.push('\n');
571
572 if dry_run {
573 body.push_str(&format!(
574 "Total: {} match(es) across {} location(s)\n",
575 total_replacements,
576 total_files_touched.len()
577 ));
578 } else {
579 body.push_str(&format!(
580 "Total: {} replacement(s) across {} location(s)\n",
581 total_replacements,
582 total_files_touched.len()
583 ));
584 }
585
586 if had_error {
587 body.push_str("\nErrors:\n");
588 for e in &error_messages {
589 body.push_str(&format!(" - {}\n", e));
590 }
591 }
592
593 let trimmed_body = body.trim_end().to_string();
594
595 let mut result = if had_error && total_replacements == 0 {
596 AgentToolResult::error(trimmed_body)
597 } else {
598 AgentToolResult::success(trimmed_body)
599 };
600
601 result.metadata = Some(json!({
602 "dry_run": dry_run,
603 "ops_count": ops.len(),
604 "paths_count": expanded_count,
605 "total_replacements": total_replacements,
606 "locations_touched": total_files_touched.len(),
607 "locations": total_files_touched.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>(),
608 "errors": error_messages,
609 }));
610
611 Ok(result)
612 }
613}