# `Mutare.Sandbox`
[🔗](https://github.com/foxbenjaminfox/mutare/blob/v0.2.1/lib/mutare/sandbox.ex#L1)

Materialise a schema as a runnable copy of the target project.

We copy the project to a dedicated directory (excluding build output), write
each metamutant over its original, and inject a tiny bootstrap into
`test/test_helper.exs` that combines `MUTARE_MUTANT_NAMESPACE` (the active
mutant's file) and `MUTARE_ACTIVE_MUTANT` (its id within that file) into one
`:persistent_term` entry before the suite starts. A second injection prefixes
`config/config.exs` with the owner-death watcher
(`Mutare.Sandbox.Command.Invocation.owner_watch_ast/0`),
so every sandbox `mix` — including the one-time compile, which runs before any
test bootstrap — halts itself if the Mutare process that spawned it dies,
instead of surviving as an orphan. Everything injected is plain Erlang/Elixir
with no dependency on Mutare, so the sandbox needs nothing added to its deps.
Sandbox `mix.exs` files also wrap `project/0` to disable signature inference
in the effective compiler options (`Mutare.Sandbox.CompilerOptions`), including
every umbrella child. `Mutare.Sandbox.ProjectEvaluation` marks escaping project
failures so the decoder retains startup-kill attribution even without project
stack frames. The target's own project files remain untouched.

One copy serves every concurrent mutant run: they share this sandbox and its
`_build`, and Mix's build lock serialises only their `--no-compile` boot check —
a small, fixed fraction of a run (measured in `NOTES.md`,
"Per-worker `MIX_BUILD_PATH` vs the shared sandbox build") — so there is no
per-worker isolation to configure.

Two materialisation modes, chosen by `:keep_sandbox`:

  * **kept (default, `keep_sandbox: true`)** — the sandbox (and its compiled `_build`)
    is *preserved* between runs and re-materialised in place by
    `Mutare.Sandbox.Mirror`: a file is rewritten only when its desired content
    differs (unchanged files keep their mtime, so mix's incremental compiler reuses
    `_build`), and files no longer mirrored, generated, or explicitly managed are pruned. The mirror carries each
    source's permission mode and recreates its symlinks (never following them),
    matching what the fresh copy preserves. Without an explicit `:sandbox` it lives at a stable per-project
    temp dir; CI pins `:sandbox` at a cached directory instead. See `NOTES.md`
    for the cache pattern.
  * **fresh (`keep_sandbox: false`)** — a throwaway dir is wiped and re-copied every run, so
    the metamutant recompiles cold, and the runner removes it afterwards. Always correct, no
    caching — the reset for a kept sandbox that has gone bad.

This module materialises the workspace. Sandbox `mix` invocations and
per-mutant timeouts are implemented in
`Mutare.Sandbox.Command.Invocation`.

# `materialized`

```elixir
@type materialized() :: %{
  seed: Mutare.Sandbox.Seed.summary(),
  declined: [{Path.t(), String.t()}]
}
```

Results of `prepare/3` for `--verbose` output, which
`Mutare.Runner.Compile` relays on the `:on_phase` hook: the app-build seed's outcome
(`t:Mutare.Sandbox.Seed.summary/0`), and each `mix.exs` whose type-signature-inference
override was not applied, paired with the reason, in path order. Such a project compiles with
inference on, which can stretch the one compile from seconds to hours, so a long compile
should not go unexplained.

# `bootstrap`

```elixir
@spec bootstrap() :: String.t()
```

The bootstrap snippet prepended to the sandbox's test helper.

# `prepare`

```elixir
@spec prepare(
  Path.t(),
  Mutare.Schema.t(),
  Mutare.Run.Context.t() | Mutare.Options.t() | keyword()
) ::
  {Path.t(), materialized()}
```

Prepare a sandbox for `schema` taken from `root`. Returns the sandbox path and a
`t:materialized/0` summary of what happened.

`opts` may be a `Mutare.Run.Context`, a `Mutare.Options` struct, or a keyword
list. `:sandbox` selects the target directory; without it Mutare uses a stable
per-project temp directory (kept mode, the default) or a fresh one (`keep_sandbox:
false`). The context's project scope controls which umbrella apps are
materialized; its hooks are not consulted — the runner reports the summary.

The sandbox must be separate from the project tree: it may not be the project
root, contain the project, or live inside it. This check is done here because it
depends on `root`.

Mutare will only use a target path that is absent, empty, or already marked as a
Mutare-owned sandbox. Any other existing path is refused without modification.
An owned directory is reused only for an explicit `:sandbox` path or when
`:keep_sandbox` is enabled. If a generated fresh-path sandbox already exists,
Mutare rejects it as a stale leftover.

# `rematerialize`

```elixir
@spec rematerialize(Path.t(), Mutare.Schema.t()) :: Path.t()
```

Re-render `schema`'s metamutants into an already-prepared `sandbox`, in place.

This is the poison-recovery path. After a failed compile removes the implicated
mutant ids and rebuilds the schema, the project copy, bootstrap, coverage
helper, and seeded builds are still valid. Only the metamutant source files may
have changed.

The rewrite is byte-aware, so unchanged files keep their timestamps and Mix
recompiles as little as possible. The same sandbox path is returned.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
