Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

recast

recast is a CLI for safe, atomic, transparent multi-file text rewrites. Pure Rust. Tuned for LLM coding agents driving mechanical edits; equally usable by humans for mechanical refactors.

Why it exists

LLM agents and shell scripts that rewrite code typically fall back to sed, sd, or a Python heredoc. All three share two silent failure modes:

  1. Silent no-match. The pattern misses, the tool exits 0, the agent moves on assuming success.
  2. Non-idempotent re-runs. Re-running a rewrite on already-rewritten text either no-ops (looks like the first run failed) or, worse, compounds the rewrite.

recast makes both impossible by default. A missing match is a non-zero exit; a non-convergent rewrite is rejected outright.

What it does

Capabilitysed / sdPython heredocrecast
Multi-file rewritemanual / yesyesyes
Match-required guardnonoyes
Idempotency checknonoyes
Atomic two-phase applynonoyes
Diff preview by defaultnonoyes
Crash-recovery sweepnonoyes
Agent-friendly JSONnonoyes
Regex patternyesyesyes
Script pattern (Rhai)noyes (Python)yes
Structural (AST)nonoyes (tree-sitter)

Status

Alpha. Tracked in PLAN.md; release notes in CHANGELOG.md.

Install

Pre-built binary

Grab the matching artifact from the Releases page.

PlatformArtifact
Linux x86_64 (glibc)recast-vX.Y.Z-x86_64-unknown-linux-gnu.tar.gz
Linux x86_64 (musl)recast-vX.Y.Z-x86_64-unknown-linux-musl.tar.gz
Linux aarch64 (glibc)recast-vX.Y.Z-aarch64-unknown-linux-gnu.tar.gz
Linux aarch64 (musl)recast-vX.Y.Z-aarch64-unknown-linux-musl.tar.gz
macOS Intelrecast-vX.Y.Z-x86_64-apple-darwin.tar.gz
macOS Apple Siliconrecast-vX.Y.Z-aarch64-apple-darwin.tar.gz

The musl builds are statically linked — drop into Alpine, distroless, or scratch containers without a glibc dependency.

Each archive ships with a .sha256 sidecar; verify before extracting:

shasum -a 256 -c recast-v0.1.3-x86_64-unknown-linux-gnu.tar.gz.sha256
tar -xzf recast-v0.1.3-x86_64-unknown-linux-gnu.tar.gz
sudo install -m 0755 recast /usr/local/bin/

Cargo install (crates.io)

cargo install recast-cli

The crate is published as recast-cli on crates.io (the bare recast name was already claimed by an unrelated serialization library). The installed binary is still called recast — every recast … command in these docs works as written.

The stock install ships every grammar, the Rhai script engine, and JSON output. Slim it down with --no-default-features and pick only the features you actually use — see Cargo features.

cargo install recast-cli --no-default-features --features lang-rust

MCP server

The Model Context Protocol server is a separate crate and a separate binary, so an agent host can run it without the CLI:

cargo install recast-mcp

Three crates ship to crates.io — recast-cli (the recast binary), recast-mcp (the recast-mcp server binary), and recast-core (the engine both link against). See crates.io/recast-mcp for the client configuration snippet.

From source

git clone https://github.com/Stoica-Mihai/recast
cd recast
cargo install --path crates/recast

Shell completions

recast --completions bash  > /etc/bash_completion.d/recast
recast --completions zsh   > ~/.config/zsh/completions/_recast
recast --completions fish  > ~/.config/fish/completions/recast.fish

Also supported: elvish, powershell.

Verify

recast --version
# recast 0.1.3

First rewrite

The three-step flow agents and humans both use.

1. Preview

recast 'OldName' 'NewName' src/

Output:

--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1 +1 @@
-fn OldName() {}
+fn NewName() {}
recast: 1 file(s) would change, 1 match(es) across 8 scanned.

No writes happen until you pass --apply.

2. Apply

recast --apply 'OldName' 'NewName' src/

Output:

recast: applying 1 file(s), 1 match(es).

Under the hood: every file is staged in a sibling .recast.tmp.N (written + fsync’d), then renamed into place via a per-file original → .recast.bak.N / temp → original swap. A failure at any step reverse-renames every committed file from its backup, leaving the tree bit-identical to the pre-image. See Safety guarantees.

3. Re-run

recast --apply 'OldName' 'NewName' src/
# recast: match-count guard violated: found 0, required at least 1
# exit 2

The second run finds nothing to match, and by default that is a guard violation — the same answer you get for a mistyped pattern, because the two are indistinguishable from the outside.

For a retry loop in CI or from an LLM agent, say so explicitly:

recast --apply --at-least 0 'OldName' 'NewName' src/
# recast: already applied; no changes needed.
# exit 0

If the pattern is non-convergent (e.g. 'a' -> 'aa'), recast refuses before writing anything, with a non_convergent_replacement, non_convergent_context, or non_convergent_script error depending on why. See Safety guarantees.

Regex mode

Default mode. Powered by the regex crate, so the syntax is Perl-compatible minus lookaround (catastrophic backtracking is gone too — regex is linear-time).

Multi-line by default

. matches \n by default (implicit (?s)); --single-line (-s) turns that off. This matches what LLMs usually expect.

Capture interpolation

recast 'fn (\w+)_old\b' 'fn ${1}_new' src/

$1, ${name} interpolated. To treat the pattern and replacement as literal text, pass --literal (-L).

Whole words

--word (-w) matches only whole words:

recast --apply --word 'foo' 'X' src/   # rewrites `foo`, leaves `foobar` alone

It wraps the pattern as \b{start-half}(?:PATTERN)\b{end-half}, the same semantics as rg --word-regexp. Those are half boundaries: each one needs only a non-word character (or the edge of the file) on its own side. Plain \b(?:PATTERN)\b is not the same thing — it needs a word character on the outside too, which makes a pattern whose own edges are punctuation impossible to match:

printf 'x -foo- y\n' > t.txt
recast --search --literal --word -- '-foo-' t.txt   # 1 match
recast --search --at-least 0 '\b(?:-foo-)\b' t.txt  # 0 matches

--word is applied after --literal escaping, so the two combine. That combination is the usual fix for a rewrite rejected as non_convergent_replacement:

recast --literal 'Outcome' 'ReadOutcome' src/           # non_convergent_replacement
recast --literal --word 'Outcome' 'ReadOutcome' src/    # fine — `ReadOutcome` no longer re-matches

--word is rejected with --lang; structural mode does not go through the regex pipeline. It does apply to --search and --stdin.

Case-insensitive

recast -i 'todo' 'TODO' .

Match-count guard

--at-least N (default 1) and --at-most N (default unbounded) bracket the total matches across all files. Violations exit 2.

recast --at-least 5 'foo' 'bar' src/       # require ≥5 matches
recast --at-most 0 'TODO' 'FIXME' src/     # CI gate: there must be zero TODOs
recast --at-least 0 'maybe' 'def' src/     # allow zero matches (no guard)

Idempotency check

The plan step reapplies the pattern to its own post-image. If any file would change again, recast aborts — the rewrite isn’t safe to run twice. Override with --allow-non-convergent if you know what you’re doing.

A rewrite can fail to converge for two different reasons, and they need different fixes, so they get different error kinds:

KindCauseFix
non_convergent_replacementThe replacement still matches the pattern. 'Outcome' -> 'ReadOutcome'Add --word, or narrow the pattern by hand
non_convergent_contextThe replacement is clean; the rewrite pulls surrounding text into a new match. 'ab' -> 'a' over aabbWord boundaries won’t help. The pattern overlaps itself; rewrite it

A third kind, non_convergent_script, covers --script runs. The script computes its replacement per match, so recast can’t say which of the two causes applies.

Examples of patterns recast rejects:

  • 'a' -> 'aa' (grows on every run)
  • 'foo' -> 'foofoo'

Examples it accepts:

  • 'old' -> 'new'
  • 'fn (\w+)_old' -> 'fn ${1}_new'

Filters

recast -t rust 'Old' 'New' .                # only Rust files
recast -T markdown 'Old' 'New' .            # everything except Markdown
recast -g '!vendor/**' 'Old' 'New' .        # exclude vendor dir
recast --no-ignore 'Old' 'New' .            # bypass .gitignore
recast --hidden 'Old' 'New' .               # include dot-files
recast --max-bytes 102400 'Old' 'New' .     # skip files > 100KiB
recast --max-files 50 'Old' 'New' .         # cap total file count

-t / -T accept the same shorthand vocabulary as ripgrep (rust, js, py, markdown, …). -g accepts ripgrep-style include/exclude globs.

Stdin mode

echo 'fn old_name() {}' | recast --stdin 'old_name' 'new_name'
# fn new_name() {}

Read one buffer, rewrite once, write to stdout. Skips the walker and commit phases. The match-count guard still applies.

Rename mode (--rename)

Apply N renames in one pass.

recast --apply --rename 'Foo=Bar' --rename 'Bar=Baz' src/

The failure this exists to prevent

Run those two renames as separate commands and they eat each other:

recast --apply '\bFoo\b' 'Bar' src/     # exit 0
recast --apply '\bBar\b' 'Baz' src/     # exit 0
#![allow(unused)]
fn main() {
// before                      // after
use Foo;                       use Baz;
use Bar;                       use Baz;
}

The original Foo and the original Bar are now the same name, and nothing complained. Both runs matched, both were convergent on their own, both exited 0. No guard in recast looks across invocations.

One --rename pass keeps them apart:

#![allow(unused)]
fn main() {
use Bar;
use Baz;
}

What a map may be

Keys match as whole words and are literal — this is a map over identifiers, not a regex pipeline. That restriction is what makes the check below decidable.

ShapeExampleVerdict
IndependentFoo=Qux, Bar=QuuxAccepted, re-runnable
ChainFoo=Bar, Bar=BazAccepted, correct exactly once
PermutationFoo=Bar, Bar=FooAccepted, correct exactly once
Self-feedingFoo=Foo BarRefused

“Correct exactly once” means a replacement reuses a name the map also renames, so a second run keeps rewriting. recast says so on stderr, and the recast_rename MCP tool returns the same sentence alongside the plan:

recast: this map is correct exactly once — a replacement reuses a name
the map also renames, so running it again would keep rewriting.

That is a statement about the map, not a warning about this run. The run itself is correct.

How the check works

recast re-applies the map to its own replacements until the text stops changing or repeats a string it has already seen.

  • Settles immediately → no replacement contains a key → re-runnable.
  • Settles after a few rounds, or returns to a string already seen → a chain or a cycle. Bounded, so correct once.
  • Never settles → the map feeds itself. Refused with rename_map_diverges.
recast --rename 'Foo=Foo Bar' src/
# recast: rename map does not settle: re-applying it to its own
# replacement of `Foo` -> `Foo Bar` kept producing new text after 64
# rounds, so the map feeds itself and grows.

This is a different question from the ordinary idempotency check. That one asks “is this rewrite a no-op the second time”; a permutation is not, and would be rejected. This one asks “does this map terminate”, which a permutation does.

Key order does not matter

Keys are sorted longest-first before they are compiled into one alternation, because the regex crate matches alternation leftmost-first. Without the sort, Foo=A listed before Foo-Bar=B would rewrite Foo-Bar into A-Bar.

recast --apply --rename 'Foo=A' --rename 'Foo-Bar=B' src/   # Foo-Bar → B
recast --apply --rename 'Foo-Bar=B' --rename 'Foo=A' src/   # same

Notes

  • OLD=NEW splits on the first =, so = may appear in NEW.
  • --rename conflicts with --script, --lang, --search, --literal, --word, --ignore-case, and --single-line. The pattern-shaping flags are rejected rather than ignored: renames are always literal and always whole-word.
  • PATTERN and REPLACEMENT are not used; pass paths directly.
  • Works with --stdin, --check, --diff, --json, the match-count guard, and every filter flag.

Script mode (--script)

When regex’s mad-libs template ($1, ${name}) can’t compute the replacement, drop in a Rhai script callback.

Quick example: version bump

cat > bump.rhai <<'RHAI'
(parse_int(captures[1]) + 1).to_string()
RHAI

echo "version 3" | recast --stdin --script bump.rhai '(\d+)' ''
# version 4

API the script sees

BindingTypeMeaning
capturesArraycaptures[0] is the full match, captures[1..] are the named/numbered groups in order
wholeStringAlias for captures[0] (match is a Rhai keyword)

The return value is coerced to string and used as the replacement.

Conditional rewrites

if captures[1] == "old" {
    "new"
} else {
    captures[1]                 // keep as-is
}

Uppercase a capture

captures[1].to_upper()

Mode notes

  • The positional REPLACEMENT argument is still required when using --script (pass ""); its value is ignored.
  • Scripted scans run sequentially — the Rhai engine isn’t Sync. That’s usually fine since --script runs are dominated by per-script work, not file I/O. Use plain regex mode for big trees.
  • Sandbox limits: 1 M operations, 1 MiB strings, 1024 array entries, expression depth 64.
  • Match-count guard, idempotency check, atomic apply, and recovery all still apply.

Structural mode (--lang)

Tree-sitter-backed AST matching. Pick a language with --lang, then pass either a friendly --ast pattern or a raw tree-sitter --query.

Supported languages

LanguageCLI nameCargo feature
Rustrust, rslang-rust
TypeScripttypescript, tslang-ts
TSXtsxlang-ts
JavaScriptjavascript, js, jsxlang-js
Pythonpython, pylang-python
Bashbash, sh, shelllang-bash
Gogo, golanglang-go
JSONjsonlang-json
Markdownmarkdown, mdlang-md

YAML / TOML are pending — the upstream tree-sitter grammars don’t yet target the v0.25 ABI recast uses.

Friendly --ast patterns

Write the pattern in the target language with $NAME (single-node) and $$$NAME (variable-shape subtree) metavars:

recast --lang rust --apply \
  --ast 'fn $NAME($$$ARGS) { $$$BODY }' \
  '' 'fn ${NAME}_v2$ARGS $BODY' \
  src/

Matches every function regardless of signature or body shape; renames it and keeps the original args + body verbatim.

Metavar rules

  • $NAME matches a single AST node at the placeholder’s position.
  • $$$NAME matches whatever the surrounding node contains (any number of statements, params, fields, etc.). The capture text is the whole wrapper node: { $$$BODY } captures { ... }, not just the inside. Templates therefore should not re-add the wrapper: write $BODY, not { $BODY }.
  • Literal identifiers in the pattern (anything that isn’t a metavar) must match exactly — fn old_name() {} only matches fn old_name() {}, not every nullary empty-body function.

Template substitution

Templates use the same $NAME / ${NAME} substitution rules. ${NAME} is needed when the name is followed by _ / alphanumeric characters that would otherwise extend the identifier:

fn ${NAME}_v2     # explicit boundary
fn $NAME_v2       # error: no capture named `NAME_v2`

Raw --query patterns

Pass a tree-sitter S-expression query directly. Use this when you need predicates (#eq?, #match?, …) or want to scope a match to a specific node kind:

recast --lang rust --apply \
  --query '((identifier) @id (#eq? @id "old_name"))' \
  '' 'new_name' src/

The capture named @root (or, absent that, the outermost capture in each match) defines the byte range to replace. Templates can reference any captured node by name ($id, ${id}).

Deleting items with their attributes (--include-leading-attrs)

Deleting a function with a plain match replaces only the function_item node — its #[test] / #[cfg(...)] attributes and /// doc comments are siblings, so they survive as orphans:

# leaves an orphaned `#[test]` behind
recast --lang rust --apply --ast 'fn drop_me() {}' '' '' src/

--include-leading-attrs extends each match backward over the contiguous run of preceding attribute_item / doc-comment siblings, so the attributes and docs go with the item:

recast --lang rust --apply --include-leading-attrs \
  --ast 'fn drop_me() {}' '' '' src/

A blank line ends the run (an attribute separated from the item by an empty line is treated as detached and left in place), and plain // / /* */ comments are never swallowed — only doc comments (///, //!, /**, /*!). The node kinds are Rust’s; languages without attribute_item simply never extend. MCP: include_leading_attrs: true on recast_structural.

Error messages

When a query fails to compile, recast surfaces a line/column-pinned error:

recast: structural: query error: tree-sitter query unknown node
  type error at line 1, column 2: zzz
  | (zzz) @x
  |  ^

When a friendly --ast pattern fails to parse, the error mentions which grammar choked and what the legal positions for metavars are.

When to use what

  • Identifier-level renames across a tree → --query '((identifier) @id (#eq? @id "X"))'.
  • Whole-construct rewrites that need shape → --ast.
  • Cross-cutting transforms that need predicates or alternatives → raw --query with #eq?, #match?, etc.

For anything more complex than what tree-sitter Query expresses, ast-grep or comby are richer. recast’s structural mode is intentionally minimal — it trades expressiveness for the same atomicity / idempotency / JSON-output guarantees the other modes have.

Agent rules snippet

If you’ve installed recast-mcp for an AI agent (Claude Code, Cursor, Continue, Claude Desktop, Cline, …), the agent gets the tools but not always the judgment about when to reach for them. Default LLM behavior is to fall back to Edit / write_file / sed because those are top-of-mind and recast feels new.

Paste the block below into your project’s agent-instructions file — whichever your runtime picks up:

RuntimeFile
Claude CodeAGENTS.md (or CLAUDE.md)
Cursor.cursor/rules/recast.md or .cursorrules
Continue.continue/rules.md
Cline.clinerules
AiderCONVENTIONS.md
Generic / otherany markdown file your agent reads at session start

The snippet doubles up with the recast-mcp server’s own instructions field (which the client injects into the system prompt during MCP handshake). Two prompts hitting the same nail — the agent hears the decision rule from both sides.

Copy-paste block

## Multi-file rewrites — use `recast` MCP tools

For ANY edit that touches the same syntactic shape in 3+ files
(renames, struct-field additions, fn-signature changes, enum-variant
reshapes, version bumps, dependency migrations), call
`recast_preview` first, inspect the diff, then `recast_apply`.

Do NOT default to:
- `Edit` / `write_file` loops over N files
- `sed -i` across the tree
- per-file copy-paste of the same transform

Those silently fail on zero matches, can't roll back mid-failure,
and blast-radius across unintended sites. The recast engine catches
all three: typed `too_few_matches` error on zero hits, atomic
two-phase commit with rollback per file, and a convergence check
that refuses non-idempotent patterns (`a` → `aa`) before any write.

### Decision flow

1. About to make the same change in **3+ files** → `recast_preview`.
2. About to make a **shape-sensitive** change (struct literal, enum
   variant, fn signature, AST node) → `recast_structural` with
   `ast_pattern` instead of regex.
3. Renaming **two or more names that touch each other** (`Foo`→`Bar`
   and `Bar`→`Baz`, or a swap) → `recast_rename` with every entry in
   ONE call. Two separate calls turn the original `Foo` and the
   original `Bar` into the same `Baz`, and both calls exit 0.
4. Preview returned 0 matches → **iterate the pattern**, do NOT fall
   back to per-file `Edit`.
5. Preview looks right → `recast_apply` with identical args.
6. A prior apply was killed mid-run → `recast_recover`.

### Concrete triggers

- "Rename `OldName` to `NewName` across the codebase"
- "Add field `direction: None` to every `ClientMessage::SplitPane { ... }` literal"
- "Update the N call sites of `pane_title(state)` to `pane_title(state, slot)`"
- "Bump every `1.2.3` to `1.2.4` in the workspace"
- "Migrate `.unwrap()` to `.expect("…")` everywhere"

### Tool pick

- `recast_apply` — regex / literal / Rhai script. Works on any
  language; text-level.
- `recast_rename` — N whole-word renames in one pass. Use whenever
  the renames touch each other; that is the case it exists for.
- `recast_structural` — tree-sitter `ast_pattern`. Use when the
  change is shape-sensitive. Supported langs: rust, ts, tsx, js,
  python, bash, go, json, markdown.
- `recast_search` — match locations only, no rewriting.
- `recast_recover` — only after a crash mid-apply.

Why this is needed (the survey)

A Claude Code session was given recast-mcp access and asked to do a multi-file refactor. The agent defaulted to Edit for every change even though recast_apply would have done the whole sweep in one call. Post-session debrief:

“Edit was top-of-mind every time. Each individual change felt small enough to justify staying in the familiar tool. The compound cost of ‘small Edit × 50’ sneaked past me.”

“I usually saw ‘this is a repeated transform across N sites’ only AFTER hitting the third or fourth site. By then I’d already done the manual edits and finishing felt cheaper than switching tools.”

The fix: tell the agent the decision rule directly. The system prompt beats latent tool-ranker preferences every time.

CLI flags

recast [OPTIONS] <PATTERN> <REPLACEMENT> [PATHS]...

Modes

FlagEffect
(default)Diff preview to stdout
--applyAtomic two-phase commit
--checkExit 1 if any file would change; no output, no writes
--stdinRead one buffer from stdin → stdout
--recoverReconcile leftover .recast.bak.* / .recast.tmp.* siblings

Match guards

FlagDefaultEffect
--at-least N1Require at least N matches across all files
--at-most N∞Require at most N matches
--allow-non-convergentoffSkip the idempotency check
--allow-syntax-errorsoffSkip the syntax-regression guard (see Safety)

Filters

FlagEffect
-t LANG, --type LANGInclude only files of this type (rust, js, py, …)
-T LANG, --type-not LANGExclude this file type
-g PAT, --glob PATInclude / exclude glob (!pat to exclude)
--hiddenInclude dot-files
--no-ignoreBypass .gitignore filtering
--max-bytes NRefuse files larger than N bytes (default 10 MiB)
--max-files NRefuse runs touching more than N files (default 1000)

Regex options

FlagEffect
-L, --literalTreat pattern + replacement as literal text
-w, --wordWhole words only, same semantics as rg --word-regexp. Rejected with --lang
--rename OLD=NEWWhole-word literal rename, repeatable, all in one pass. See Rename mode
-i, --ignore-caseCase-insensitive
-s, --single-lineDisable implicit (?s) — . no longer matches \n

Script mode

FlagEffect
--script PATHRhai script file run per match; return value = replacement

Structural mode

FlagEffect
--lang LANGTree-sitter grammar (rust, ts, python, …)
--query QUERYRaw tree-sitter S-expression query
--ast PATTERNFriendly source-shaped pattern with $NAME metavars
--include-leading-attrsExtend each match backward over leading #[attr] / doc-comment lines (see Structural mode)

Output

FlagEffect
--jsonEmit single-line JSON on stdout
--quietSuppress diff body; print only the summary
-v, --verbosePer-file timing and counters

Misc

FlagEffect
--threads NWorker threads (default = num CPUs)
--forceBypass the workspace lock check
--completions SHELLPrint shell completion script to stdout
--helpHelp summary
--versionVersion string

JSON output schema

--json emits exactly one line of compact JSON on stdout per invocation. Snapshot-locked in crates/recast-core/src/snapshots/ — changing field names or order is a breaking change.

Errors go to stdout too (not stderr) so agents have a single stream to parse.

Common shape

Every report carries a kind discriminator:

kind ∈ "plan" | "apply" | "check" | "error"

Non-error reports share outcome, files_scanned, and total_matches as a header that appears in that order; the mode-specific count (files_changed / files_written / files_would_change) follows.

plan (default mode)

{
  "kind": "plan",
  "outcome": "changes" | "already_applied",
  "files_scanned": 5,
  "total_matches": 3,
  "files_changed": 2,
  "changes": [
    { "path": "src/a.rs", "matches": 2 },
    { "path": "src/b.rs", "matches": 1 }
  ]
}

apply

{
  "kind": "apply",
  "outcome": "changes" | "already_applied",
  "files_scanned": 5,
  "total_matches": 3,
  "files_written": 2
}

check

{
  "kind": "check",
  "outcome": "changes" | "already_applied",
  "files_scanned": 5,
  "total_matches": 3,
  "files_would_change": 2
}

error

{
  "kind": "error",
  "error":
      "too_few_matches"
    | "too_many_matches"
    | "non_convergent_replacement"
    | "non_convergent_context"
    | "non_convergent_script"
    | "invalid_rename_map"
    | "rename_map_diverges"
    | "too_many_files"
    | "file_too_large"
    | "invalid_regex"
    | "invalid_glob"
    | "walk"
    | "io"
    | "script_parse"
    | "script_runtime"
    | "unknown_language"
    | "structural_query"
    | "structural_template"
    | "structural_parse"
    | "locked"
    | "invalid_threads"
    | "thread_pool",
  "message": "human-readable description",
  "exit_code": 2 | 3,
  "remedies": [
      "at_least" | "at_most" | "max_bytes" | "max_files"
    | "word" | "allow_non_convergent" | "allow_syntax_errors"
    | "force_lock" | "threads"
  ]
}

The exit_code field mirrors the process exit code so agents can branch on kind: "error" without re-reading $?.

remedies lists the knobs that could clear this error, most useful first, and is empty when the fix is to change the pattern or the tree instead. Branch on it rather than reading message, which is prose. recast-core names the knob, not the flag, because it does not know whether it is serving the CLI or the MCP server — so the same non_convergent_replacement reports ["word", "allow_non_convergent"] here, prints see --word / --allow-non-convergent on the CLI, and says set word or allow_non_convergent over MCP.

One knob is deliberately CLI-only. force_lock appears in this list but has no MCP argument: a crashed holder releases its flock on exit, so a held lock always means a live process is mid-apply, and the MCP locked error returns "remedies": []. Wait and retry, or stop and ask — there is nothing to set.

Stability

Every shape above has an insta snapshot test. Any PR that changes field names, drops a field, or reorders them shows up as a snapshot diff in review — there’s no quiet schema drift.

Exit codes

CodeMeaning
0Success, or “no changes needed”
1--check set and at least one file would change
2Match-count guard violated (--at-least / --at-most)
3Internal error (regex / glob parse, I/O, non-convergent pattern, syntax regression, script error, structural query error, workspace lock held, …)

Agents can branch on these without parsing stdout. Combined with --json, exit-code 2 always pairs with kind: "error" + error: "too_few_matches" or "too_many_matches", and exit-code 3 with one of the remaining error discriminants.

Examples

recast --check --at-least 0 'TODO' 'FIXME' .
echo "exit=$?"
# exit=0 → no files would change (clean)
# exit=1 → at least one file would change (CI gate fail)

recast --at-least 5 'foo' 'bar' src/
# exit=2 → fewer than 5 matches; nothing applied

A --check gate needs --at-least 0. Without it, a clean tree is a zero-match run and exits 2, not 0 — the guard fires before --check classifies anything. That is deliberate: the guard cannot tell a clean tree from a pattern you mistyped, so it makes you say which one you meant. A CI gate that greps for something it expects to be absent wants --at-least 0; one that expects matches and asserts they all got rewritten wants the default.

Safety guarantees

The six things that make recast safer than sed / sd / a Python heredoc.

1. Match-required guard

Default --at-least 1 makes a silent zero-match exit impossible. An agent that types the wrong pattern gets an immediate non-zero exit instead of “looks like it worked”.

Override with --at-least 0 if you really do want to allow no-op runs.

2. Idempotency / convergence

Before any write, recast re-applies the rewrite to its own post-image. If any file would change again, the run is aborted with one of three errors, split by cause because the fixes differ: non_convergent_replacement (the replacement still matches the pattern — narrow it, usually with word boundaries), non_convergent_context (the replacement is clean but the rewrite pulls surrounding text into a new match — word boundaries will not help), and non_convergent_script (a --script run, where the replacement is computed per match so the cause is not knowable up front). All three name --allow-non-convergent as the override.

Examples that pass:

  • 'old' -> 'new'
  • 'fn (\w+)_old' -> 'fn ${1}_new'

Examples that get rejected:

  • 'a' -> 'aa' (grows on every run)
  • 'foo' -> 'foofoo'

A successful first run followed by a re-run has nothing left to match, so it is the guard in section 1 that decides the outcome, not convergence. Re-run with --at-least 0 to get already_applied and exit 0; the default --at-least 1 exits 2, because from the planner’s side an already-converted tree and a mistyped pattern are the same observation.

3. Syntax-regression guard

For every changed file whose extension maps to a compiled tree-sitter grammar (.rs, .ts, .tsx, .js/.mjs/.cjs/.jsx, .py, .sh/.bash, .go, .json, .md), recast re-parses the post-image and counts parse errors. If the rewrite introduces new errors relative to the pre-image, the run is aborted with a syntax_regression error before anything is written.

The comparison is a count delta, so a file that was already unparsable (mid-refactor, exotic macro) stays acceptable as long as the rewrite doesn’t make it worse. This catches a greedy regex that strands a brace or truncates an expression.

# regex deletes the `fn open(` line but leaves the body + closing brace
recast --apply 'fn open\(\) \{\n' '' src/   # → syntax_regression, nothing written

Limitation — syntactic, not semantic. The guard sees parse errors, not compiler errors. Deleting a function body while leaving its #[test] attribute behind produces valid syntax (the attribute just binds to the next item); tree-sitter does not flag it, so the guard does not fire. That class is a job for cargo check or, better, for structural mode, which removes the attribute along with the item.

Override per run with --allow-syntax-errors (CLI) / allow_syntax_errors: true (MCP). Files with no compiled grammar — and --no-default-features builds with every lang-* feature off — skip the guard and pass through unchecked.

4. Two-phase atomic apply

Phase A (stage)   per file: write sibling .recast.tmp, fsync, copy mode
Phase B (commit)  per file: rename original→.recast.bak, rename .tmp→original
Phase C (cleanup) per dir:  delete backups, fsync parent dir

Any failure in Phase B walks the rename log in reverse — every already-renamed file is restored from its backup, leaving the tree bit-identical to the pre-image. Failure in Phase A just deletes the staged temps; originals are never touched.

This applies to regex, script, and structural modes.

5. Crash-recovery sweep

If the process dies mid-commit (SIGKILL, panic, power loss), the tree may be left with leftover .foo.recast.bak.N / .foo.recast.tmp.N siblings. Reconcile with:

recast --recover src/
  • Target exists + stale backup/temp → delete leftovers
  • Target missing + backup present → rename newest backup back to target
  • Target missing + only temps → leave untouched (can’t safely decide)

6. Workspace lock

--apply and --recover take an exclusive non-blocking lock so two concurrent rewrites against the same tree don’t interleave. The second invocation gets an immediate locked error with exit 3 instead of corrupting the tree. The same lock now covers the MCP recast_apply, recast_structural (with apply), and recast_recover tools.

What the lock is keyed on. The enclosing VCS checkout — the nearest ancestor holding .git, .hg, .jj, or .svn — not the paths a given invocation happens to name. So recast --apply … src/ and recast --apply … src/sub/ contend with each other, as do two runs from different working directories.

Where the file lives. $XDG_RUNTIME_DIR/recast/<hash>.lock, or the system temp directory when XDG_RUNTIME_DIR is unset. Never inside your working tree, so a rewrite leaves no untracked file behind. The name is a stable hash of the canonical root; the error message names the tree so you don’t have to decode it.

The file is never deleted, on purpose. Unlinking a lockfile on release breaks mutual exclusion: a process still holding a descriptor on the old inode and a process creating a fresh file at the same path end up locking two different objects, and both succeed. The leftover files are zero bytes and live in a directory the system clears.

Limits worth knowing.

  • The lock is advisory and per-user. $XDG_RUNTIME_DIR is mode 0700, so two different users rewriting one shared tree will not see each other’s lock.
  • A tree with no VCS marker has no principled root, so it falls back to the common ancestor of the path arguments. For those trees the src/ versus src/sub/ gap above still applies.
  • Atomicity is per-invocation, not cross-invocation. The lock stops interleaving; it is not a transaction across separate runs.

--force bypasses the lock for cases you genuinely understand (e.g., the previous holder crashed and you’ve already run --recover). --check and --diff skip the lock since they don’t write.

Cargo features

recast (the binary) and recast-core (the library) expose a matching set of cargo features so users can opt out of grammars or modes they don’t need.

recast binary

FeatureDefaultPulls in
script✅recast-core/script (Rhai callback)
lang-rust✅tree-sitter + tree-sitter-rust
lang-ts✅tree-sitter + tree-sitter-typescript
lang-js✅tree-sitter + tree-sitter-javascript
lang-python✅tree-sitter + tree-sitter-python
lang-bash✅tree-sitter + tree-sitter-bash
lang-go✅tree-sitter + tree-sitter-go
lang-json✅tree-sitter + tree-sitter-json
lang-md✅tree-sitter + tree-sitter-md
lang-all✅Meta — enables every lang-* above

Stock install:

cargo install --path crates/recast

Slim install — Rust grammar only, no script engine:

cargo install --path crates/recast \
  --no-default-features \
  --features lang-rust

Slim install — Python + TypeScript only:

cargo install --path crates/recast \
  --no-default-features \
  --features lang-python,lang-ts

recast-core library

The library exposes the same lang-* features plus serde (JSON output schema). At least one lang-* must be enabled for structural mode to compile in.

Binary size

Each grammar adds ~5–15 MB of compiled tables. The full --features lang-all binary is ~80 MB. A slim lang-rust-only build is ~25 MB. Static musl builds are a few MB larger.

Building from source

git clone https://github.com/Stoica-Mihai/recast
cd recast
cargo build --release --workspace --all-features

The full test + lint matrix CI runs:

cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features
cargo doc --workspace --no-deps --all-features      # RUSTDOCFLAGS=-D warnings in CI

Property tests run as part of cargo test; criterion benchmarks are opt-in:

cargo bench --features lang-rust,script -p recast-core

HTML bench reports land under target/criterion/.

Cutting a release

  1. Bump version in Cargo.toml (workspace) and the recast-core path-dep pin.

  2. Promote ## [Unreleased] → ## [X.Y.Z] — YYYY-MM-DD in CHANGELOG.md.

  3. Commit, tag, push:

    git commit -am "chore: bump to X.Y.Z"
    git tag -a vX.Y.Z -m "vX.Y.Z — short tagline"
    git push origin main
    git push origin vX.Y.Z
    
  4. .github/workflows/release.yml cross-compiles seven targets, packages each, extracts the [X.Y.Z] CHANGELOG section as release notes, and attaches everything to the matching GitHub Release. Workflow_dispatch re-runs gh release edit --notes-file against an existing tag.

Operating manual

The full operating manual for AI agents and human contributors lives in AGENTS.md (symlinked as CLAUDE.md for agent runtimes that look it up by that name).

Highlights:

  • TDD by default — every feature goes through /tdd:tdd
  • DRY enforced — every feature/fix runs through /engineering-principles:dry-principle
  • Tests live in their own files (foo_tests.rs next to foo.rs)
  • Property tests via proptest for every public entry point
  • Snapshot tests via insta for JSON + unified-diff output
  • Conventional commit prefixes; one concern per PR
  • No Co-Authored-By trailers
  • README + CHANGELOG + PLAN are documentation contracts — drift is a bug
  • Release process is fully automated from tag push

See the canonical file for the full set of rules and the reasoning behind each one.