Skip to main content

oxicode_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    // SAFETY: the command was spawned with `Stdio::piped()` for stdout/stderr
248    // and the spawn succeeded (we returned early on error), so both `take()`
249    // calls cannot return None.
250    #[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        // Cap dry-run stdout so a runaway codebase doesn't OOM the agent.
260        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
284/// Count and group dry-run matches from `sg --json=stream` output.
285///
286/// Each non-empty line is one match JSON object. We only need a count and
287/// per-file breakdown, not the full payload, so malformed lines are
288/// silently skipped (counted toward total if they at least parsed as JSON).
289fn 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
314/// Parse `Applied N changes` out of `sg -U` stderr.
315fn 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
327/// Split a large path list into chunks no larger than `MAX_PATHS_PER_INVOCATION`.
328fn 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        // Bulk file rewrites that go through `sg -U` mutate many files at
394        // once. Defaulting to ParallelSafe would let a concurrent `edit` or
395        // another `ast_edit` race on the same paths and corrupt output.
396        // Force sequential execution per batch, matching eval_tool /
397        // debug_tool / browse_tool. This is correct regardless of the
398        // call's dry_run flag (execution_mode is static per-tool, not
399        // per-call).
400        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        // ── 1. Validate inputs ───────────────────────────────────────
415        let ops = parse_ops(&params)?;
416        let raw_paths = parse_paths(&params)?;
417
418        // Default dry_run = true (per spec); do NOT copy edit.rs's default.
419        let dry_run = params
420            .get("dry_run")
421            .and_then(Value::as_bool)
422            .unwrap_or(true);
423
424        // ── 2. Resolve & expand paths under the tool's root guard ─────
425        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        // ── 3. Run each op × each path-chunk ─────────────────────────
436        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        // Chunk the path list once and re-use across every op — avoids
446        // re-cloning the (potentially large) expanded list per op.
447        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                            // Strip the noisy deprecation banner if it's the
460                            // only thing on stderr.
461                            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                            // Otherwise: non-zero exit with empty output —
494                            // some sg builds signal "no matches" this way.
495                            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                            // sg doesn't tell us which files it touched, so
507                            // we assume the entire path-chunk was in scope.
508                            for p in chunk {
509                                op_files.insert(p.clone());
510                            }
511                        } else {
512                            // Apply succeeded but we couldn't parse the
513                            // count — surface what we know.
514                            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        // ── 4. Format output ─────────────────────────────────────────
556        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}