Skip to main content

oxi_agent/tools/
ast_edit.rs

1//! AST-edit tool — structural code rewriting via the `sg` (ast-grep) CLI.
2//!
3//! Like the `ast_grep` search tool but applies pattern-driven rewrites.
4//! Accepts an array of `{ pat, out }` operations applied against a list of
5//! files / directories / globs.
6//!
7//! ## Empirical CLI behavior (verified against ast-grep 0.45.0)
8//!
9//! - `sg` does **not** expand shell globs in positional path args:
10//!   `sg -p P --json 'src/**/*.rs'` errors with `No such file or directory`.
11//!   This tool expands globs via the `glob` crate before invoking `sg`.
12//! - `-U` (`--update-all`) is **silently ignored** when `--json` is set: the
13//!   process exits 0, prints JSON, and the file is untouched. We therefore
14//!   drop `--json` for the apply pass.
15//! - With `-U` and no `--json`, `sg` writes changes in place AND prints
16//!   `Applied N changes` on **stderr** — so we get the replacement count
17//!   without a second dry-run pass.
18//! - `--json=stream` emits one JSON object per match per line on stdout,
19//!   making it cheap to count and group by file.
20//! - `sg` accepts any number of positional paths (directories + concrete
21//!   files + globs-expanded files) in a single invocation, so we issue one
22//!   process per (op × path-chunk) and never loop over individual paths.
23//!
24//! ## Modes
25//!
26//! - `dry_run=true` (default) — previews via `sg -p P -r R --json=stream`.
27//!   No files are modified; counts come from stdout line count.
28//! - `dry_run=false` — applies via `sg -p P -r R -U`. Counts come from the
29//!   `Applied N changes` line on stderr.
30use 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
40/// Cap on how many paths we'll pass to a single `sg` invocation.
41/// `sg` walks directories internally, so this only counts the input list.
42const MAX_PATHS_PER_INVOCATION: usize = 512;
43
44/// Cap on stdout we'll buffer for a dry-run preview (bytes). ast-grep's
45/// `--json=stream` output is one line per match, so 8 MiB is generous.
46const DRY_RUN_STDOUT_CAP: usize = 8 * 1024 * 1024;
47
48/// One pattern → replacement operation supplied by the caller.
49#[derive(Debug, Clone)]
50struct RewriteOp {
51    pat: String,
52    out: String,
53}
54
55/// AstEditTool — wraps the `sg` (ast-grep) CLI for structural rewriting.
56pub struct AstEditTool {
57    root_dir: Option<PathBuf>,
58}
59
60impl AstEditTool {
61    /// Create with no explicit root (uses ToolContext.root() at runtime).
62    pub fn new() -> Self {
63        Self { root_dir: None }
64    }
65
66    /// Create with a specific working directory (overrides ToolContext).
67    pub fn with_cwd(cwd: PathBuf) -> Self {
68        Self {
69            root_dir: Some(cwd),
70        }
71    }
72
73    /// Resolve a single user-supplied path string against the tool root.
74    /// Absolute paths pass through; relative paths join onto the root.
75    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    /// True if the path string contains shell-glob wildcard characters.
85    fn looks_like_glob(raw: &str) -> bool {
86        raw.contains('*') || raw.contains('?') || raw.contains('[')
87    }
88
89    /// Expand a list of user paths (files, dirs, or globs) into a flat list
90    /// of `sg`-ready positional args: a mix of concrete file paths and
91    /// directories (which `sg` walks itself).
92    ///
93    /// `sg` does not expand globs in positional args (verified: it errors
94    /// with `No such file or directory`), so we expand them here via the
95    /// `glob` crate. Directories pass through unchanged.
96    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                    // `sg` walks directories, so we can pass them through
111                    // too — but only when the glob explicitly targets a
112                    // directory (rare). For file globs, filter to files.
113                    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
149/// Parse `ops` from the input `Value`.
150fn 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
183/// Parse `paths` from the input `Value`.
184fn 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
205/// Spawn `sg` for a single (op, path-chunk) and return its `(status, stdout, stderr)`.
206///
207/// For dry-run previews we use `--json=stream` (one JSON object per match
208/// per line — cheap to count, easy to group by file). For real rewrites we
209/// drop `--json` and add `-U` because ast-grep silently ignores `-U` when
210/// `--json` is set; `Applied N changes` then lands on stderr.
211async 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        // --json=stream: one match per line, easy to count.
221        cmd.arg("--json=stream");
222    } else {
223        // Real rewrite. ast-grep ignores -U when --json is set, so we MUST
224        // omit --json here. The `Applied N changes` summary lands on stderr.
225        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        // Cap dry-run stdout so a runaway codebase doesn't OOM the agent.
255        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
279/// Count and group dry-run matches from `sg --json=stream` output.
280///
281/// Each non-empty line is one match JSON object. We only need a count and
282/// per-file breakdown, not the full payload, so malformed lines are
283/// silently skipped (counted toward total if they at least parsed as JSON).
284fn 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
309/// Parse `Applied N changes` out of `sg -U` stderr.
310fn 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
322/// Split a large path list into chunks no larger than `MAX_PATHS_PER_INVOCATION`.
323fn 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        // Bulk file rewrites that go through `sg -U` mutate many files at
389        // once. Defaulting to ParallelSafe would let a concurrent `edit` or
390        // another `ast_edit` race on the same paths and corrupt output.
391        // Force sequential execution per batch, matching eval_tool /
392        // debug_tool / browse_tool. This is correct regardless of the
393        // call's dry_run flag (execution_mode is static per-tool, not
394        // per-call).
395        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        // ── 1. Validate inputs ───────────────────────────────────────
410        let ops = parse_ops(&params)?;
411        let raw_paths = parse_paths(&params)?;
412
413        // Default dry_run = true (per spec); do NOT copy edit.rs's default.
414        let dry_run = params
415            .get("dry_run")
416            .and_then(Value::as_bool)
417            .unwrap_or(true);
418
419        // ── 2. Resolve & expand paths under the tool's root guard ─────
420        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        // ── 3. Run each op × each path-chunk ─────────────────────────
431        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        // Chunk the path list once and re-use across every op — avoids
441        // re-cloning the (potentially large) expanded list per op.
442        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                            // Strip the noisy deprecation banner if it's the
455                            // only thing on stderr.
456                            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                            // Otherwise: non-zero exit with empty output —
489                            // some sg builds signal "no matches" this way.
490                            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                            // sg doesn't tell us which files it touched, so
502                            // we assume the entire path-chunk was in scope.
503                            for p in chunk {
504                                op_files.insert(p.clone());
505                            }
506                        } else {
507                            // Apply succeeded but we couldn't parse the
508                            // count — surface what we know.
509                            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        // ── 4. Format output ─────────────────────────────────────────
551        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}