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 let mut stdout = child.stdout.take().expect("piped stdout");
248 let mut stderr = child.stderr.take().expect("piped stderr");
249
250 let mut stdout_buf = Vec::new();
251 let mut stderr_buf = Vec::new();
252
253 if dry_run {
254 let mut limited = stdout.take(DRY_RUN_STDOUT_CAP as u64);
256 let (s_res, e_res) = tokio::join!(
257 AsyncReadExt::read_to_end(&mut limited, &mut stdout_buf),
258 stderr.read_to_end(&mut stderr_buf)
259 );
260 s_res.map_err(|e| format!("Failed reading `sg` stdout: {e}"))?;
261 e_res.map_err(|e| format!("Failed reading `sg` stderr: {e}"))?;
262 } else {
263 let (s_res, e_res) = tokio::join!(
264 stdout.read_to_end(&mut stdout_buf),
265 stderr.read_to_end(&mut stderr_buf)
266 );
267 s_res.map_err(|e| format!("Failed reading `sg` stdout: {e}"))?;
268 e_res.map_err(|e| format!("Failed reading `sg` stderr: {e}"))?;
269 }
270
271 let status = child
272 .wait()
273 .await
274 .map_err(|e| format!("Failed waiting on `sg`: {e}"))?;
275
276 Ok((status, stdout_buf, stderr_buf))
277}
278
279fn summarise_dry_run(stdout: &[u8]) -> (usize, std::collections::BTreeMap<PathBuf, usize>) {
285 let mut total = 0usize;
286 let mut by_file: std::collections::BTreeMap<PathBuf, usize> = std::collections::BTreeMap::new();
287
288 for line in stdout.split(|b| *b == b'\n') {
289 let trimmed: Vec<u8> = line
290 .iter()
291 .copied()
292 .skip_while(|b| b.is_ascii_whitespace())
293 .take_while(|b| !b.is_ascii_whitespace())
294 .collect();
295 if trimmed.is_empty() {
296 continue;
297 }
298 if let Ok(v) = serde_json::from_slice::<Value>(&trimmed) {
299 if let Some(file) = v.get("file").and_then(Value::as_str) {
300 *by_file.entry(PathBuf::from(file)).or_insert(0) += 1;
301 }
302 total += 1;
303 }
304 }
305
306 (total, by_file)
307}
308
309fn parse_applied_count(stderr: &[u8]) -> Option<usize> {
311 let text = String::from_utf8_lossy(stderr);
312 for line in text.lines() {
313 let trimmed = line.trim();
314 if let Some(rest) = trimmed.strip_prefix("Applied ") {
315 let num = rest.split_whitespace().next()?;
316 return num.parse::<usize>().ok();
317 }
318 }
319 None
320}
321
322fn chunk_paths(paths: Vec<PathBuf>) -> Vec<Vec<PathBuf>> {
324 if paths.len() <= MAX_PATHS_PER_INVOCATION {
325 return vec![paths];
326 }
327 paths
328 .chunks(MAX_PATHS_PER_INVOCATION)
329 .map(|c| c.to_vec())
330 .collect()
331}
332
333#[async_trait]
334impl AgentTool for AstEditTool {
335 fn name(&self) -> &str {
336 "ast_edit"
337 }
338
339 fn label(&self) -> &str {
340 "AST Edit"
341 }
342
343 fn description(&self) -> &str {
344 "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."
345 }
346
347 fn parameters_schema(&self) -> Value {
348 json!({
349 "type": "object",
350 "properties": {
351 "ops": {
352 "type": "array",
353 "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.",
354 "items": {
355 "type": "object",
356 "properties": {
357 "pat": {
358 "type": "string",
359 "description": "AST pattern in ast-grep syntax (e.g. 'fn $NAME() -> i32 { $BODY }')."
360 },
361 "out": {
362 "type": "string",
363 "description": "Replacement template (e.g. 'fn $NAME() -> i64 { $BODY }')."
364 }
365 },
366 "required": ["pat", "out"],
367 "additionalProperties": false
368 },
369 "minItems": 1
370 },
371 "paths": {
372 "type": "array",
373 "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.",
374 "items": { "type": "string" },
375 "minItems": 1
376 },
377 "dry_run": {
378 "type": "boolean",
379 "description": "When true (default), only preview matches — no files are modified. When false, apply the rewrites in place using ast-grep's `--update-all`.",
380 "default": true
381 }
382 },
383 "required": ["ops", "paths"]
384 })
385 }
386
387 fn execution_mode(&self) -> ToolExecutionMode {
388 ToolExecutionMode::SequentialOnly
396 }
397
398 fn intent(&self) -> Option<&str> {
399 Some("Applying AST rewrites")
400 }
401
402 async fn execute(
403 &self,
404 _tool_call_id: &str,
405 params: Value,
406 _signal: Option<oneshot::Receiver<()>>,
407 ctx: &ToolContext,
408 ) -> Result<AgentToolResult, ToolError> {
409 let ops = parse_ops(¶ms)?;
411 let raw_paths = parse_paths(¶ms)?;
412
413 let dry_run = params
415 .get("dry_run")
416 .and_then(Value::as_bool)
417 .unwrap_or(true);
418
419 let root = self.root_dir.as_deref().unwrap_or_else(|| ctx.root());
421 let guard = PathGuard::new(root);
422
423 let expanded = Self::expand_paths(&raw_paths, root)?;
424 for p in &expanded {
425 guard
426 .validate(p)
427 .map_err(|e| format!("Path '{}' rejected: {}", p.display(), e))?;
428 }
429
430 let mut total_replacements: usize = 0;
432 let mut total_files_touched: std::collections::BTreeSet<PathBuf> =
433 std::collections::BTreeSet::new();
434 let mut per_op_summary: Vec<String> = Vec::with_capacity(ops.len());
435 let mut had_error = false;
436 let mut error_messages: Vec<String> = Vec::new();
437
438 let expanded_count = expanded.len();
439
440 let chunks = chunk_paths(expanded);
443
444 for (op_idx, op) in ops.iter().enumerate() {
445 let mut op_count: usize = 0;
446 let mut op_files: std::collections::BTreeSet<PathBuf> =
447 std::collections::BTreeSet::new();
448
449 for chunk in &chunks {
450 match run_sg_for_op(op, chunk, dry_run).await {
451 Ok((status, stdout, stderr)) => {
452 if !status.success() {
453 let stderr_text = String::from_utf8_lossy(&stderr).trim().to_string();
454 let cleaned: String = stderr_text
457 .lines()
458 .filter(|l| {
459 !l.contains("`sg` is deprecated")
460 && !l.contains("Use `ast-grep` instead")
461 && !l.starts_with("======")
462 && !l.trim().is_empty()
463 })
464 .collect::<Vec<_>>()
465 .join(" ");
466
467 let stdout_text = String::from_utf8_lossy(&stdout).trim().to_string();
468
469 if !cleaned.is_empty() {
470 had_error = true;
471 error_messages.push(format!(
472 "ops[{}] (pat='{}') failed (exit {:?}): {}",
473 op_idx,
474 op.pat,
475 status.code(),
476 cleaned
477 ));
478 } else if !stdout_text.is_empty() {
479 had_error = true;
480 error_messages.push(format!(
481 "ops[{}] (pat='{}') failed (exit {:?}): {}",
482 op_idx,
483 op.pat,
484 status.code(),
485 stdout_text
486 ));
487 }
488 continue;
491 }
492
493 if dry_run {
494 let (count, by_file) = summarise_dry_run(&stdout);
495 op_count += count;
496 for (f, _n) in by_file {
497 op_files.insert(f);
498 }
499 } else if let Some(n) = parse_applied_count(&stderr) {
500 op_count += n;
501 for p in chunk {
504 op_files.insert(p.clone());
505 }
506 } else {
507 for p in chunk {
510 op_files.insert(p.clone());
511 }
512 error_messages.push(format!(
513 "ops[{}] (pat='{}'): apply succeeded but could not parse 'Applied N changes' from stderr",
514 op_idx, op.pat
515 ));
516 }
517 }
518 Err(msg) => {
519 had_error = true;
520 error_messages.push(format!("ops[{}]: {}", op_idx, msg));
521 }
522 }
523 }
524
525 total_replacements += op_count;
526 for f in &op_files {
527 total_files_touched.insert(f.clone());
528 }
529
530 let files_label = if op_files.is_empty() {
531 "0 files".to_string()
532 } else {
533 format!("{} location(s)", op_files.len())
534 };
535
536 let summary_line = if dry_run {
537 format!(
538 "ops[{}] pat='{}': {} match(es) across {}",
539 op_idx, op.pat, op_count, files_label
540 )
541 } else {
542 format!(
543 "ops[{}] pat='{}' → out='{}': {} replacement(s) across {}",
544 op_idx, op.pat, op.out, op_count, files_label
545 )
546 };
547 per_op_summary.push(summary_line);
548 }
549
550 let header = if dry_run {
552 "AST edit preview (dry-run) — no files modified"
553 } else {
554 "AST edit applied"
555 };
556
557 let mut body = String::new();
558 body.push_str(header);
559 body.push('\n');
560 body.push('\n');
561 for line in &per_op_summary {
562 body.push_str(line);
563 body.push('\n');
564 }
565 body.push('\n');
566
567 if dry_run {
568 body.push_str(&format!(
569 "Total: {} match(es) across {} location(s)\n",
570 total_replacements,
571 total_files_touched.len()
572 ));
573 } else {
574 body.push_str(&format!(
575 "Total: {} replacement(s) across {} location(s)\n",
576 total_replacements,
577 total_files_touched.len()
578 ));
579 }
580
581 if had_error {
582 body.push_str("\nErrors:\n");
583 for e in &error_messages {
584 body.push_str(&format!(" - {}\n", e));
585 }
586 }
587
588 let trimmed_body = body.trim_end().to_string();
589
590 let mut result = if had_error && total_replacements == 0 {
591 AgentToolResult::error(trimmed_body)
592 } else {
593 AgentToolResult::success(trimmed_body)
594 };
595
596 result.metadata = Some(json!({
597 "dry_run": dry_run,
598 "ops_count": ops.len(),
599 "paths_count": expanded_count,
600 "total_replacements": total_replacements,
601 "locations_touched": total_files_touched.len(),
602 "locations": total_files_touched.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>(),
603 "errors": error_messages,
604 }));
605
606 Ok(result)
607 }
608}