# `GgenIgniter.Actuate`
[🔗](https://github.com/seanchatmangpt/ggen_igniter/blob/v26.9.8/lib/ggen_igniter/actuate.ex#L1)

Writes a rendered source string to a file, with write-safety guards modeled
on the real Rust ggen's `ggen-engine/src/write.rs` decision table (idempotent
no-op detection, `unless_exists`, `skip_if`).

Three real actuation paths exist, all driven from `Mix.Tasks.GgenIgniter.Sync`:

  * `write_new_file!/2` -- unconditional create, used internally.
  * `write_file!/3` (`mode: file`, no `inject:`) -- guarded whole-file
    write/no-op/skip, the default for a `mode: file` template.
  * `inject_content!/5` (`mode: file`, frontmatter `inject: true`) -- guarded
    splice into an EXISTING file, anchored on the template's `before:`/
    `after:`/`at_line:` frontmatter field. `Mix.Tasks.GgenIgniter.Sync`
    resolves the frontmatter's `before`/`after`
    (`GgenIgniter.Frontmatter.MatchSpec.t()`) into this function's real
    `marker` arg (`String.t() | Regex.t() | nil`) -- see
    `Mix.Tasks.GgenIgniter.Sync`'s private `match_spec_to_marker!/2` for the
    literal-vs-structured `MatchRule` conversion, including which
    `matcher`/`scope`/`occurrence`/`trim` combinations are honored and which
    raise a named "not yet supported" error rather than being silently
    dropped.

Igniter AST-patch actuation (a real `Sourceror`/`Igniter.Code`-based
structural patch, as opposed to this module's line-anchored text splice) for
incremental changes to an EXISTING file remains an explicit, disclosed
follow-on -- not implemented this pass (see pack.toml).

## Atomic-write guarantee (`write_file!/3`'s `:written` outcome only)

When `write_file!/3` actually writes (the `:written` outcome, real -- not
`:dry_run`), it does so via a real write-to-temp-then-`File.rename!/2`
sequence, not a direct `File.write!/2` to the final path:

  1. Render the content to a sibling temp file in the SAME directory as the
     final path (e.g. `path <> ".ggen_igniter.tmp.<unique_integer>"`) --
     same directory, so the subsequent rename is guaranteed to stay on the
     same filesystem/mount (a cross-filesystem rename is not atomic and, on
     most platforms, simply fails rather than silently copying).
  2. `File.write!/2` the full content to that temp file.
  3. Best-effort `fsync` the temp file's file descriptor via `:file.sync/1`
     (when the OS/filesystem honors fsync -- see caveats below) before the
     rename, so the temp file's bytes are durable before it becomes visible
     under the final name.
  4. `File.rename!/2` the temp file onto the final `path`.

**What this actually guarantees, precisely:** on POSIX filesystems (Linux
ext4/xfs, macOS APFS/HFS+) where `rename(2)` is atomic per the POSIX
standard, an observer of `path` NEVER sees a partially-written file --
`path` either still holds its old content (rename hasn't happened yet) or
the new content (rename has happened), never a half-written intermediate
state, even if this process is killed mid-write. This holds because the
temp file is invisible under `path`'s name until the single atomic rename
syscall completes.

**What this does NOT guarantee, stated honestly rather than implied:**

  * **Windows**: `File.rename!/2` on Windows (`MoveFileEx`-backed) is not
    guaranteed atomic when the destination already exists on all Windows
    filesystem/OS version combinations the way POSIX `rename(2)` is --
    Erlang/OTP's underlying implementation has evolved across versions and
    is not something this module independently verifies here. Treat the
    atomicity guarantee above as POSIX-only.
  * **NFS and other network filesystems**: `rename(2)` atomicity is a
    LOCAL-filesystem POSIX guarantee. NFS (especially NFSv3) has documented
    non-atomic-rename edge cases under concurrent access from multiple
    clients. If `path` lives on an NFS mount, this guarantee weakens to
    "best effort," not "atomic."
  * **fsync durability**: step 3's `:file.sync/1` call is best-effort --
    it's issued when available, but this module does not verify the
    underlying storage/OS actually honors the fsync barrier (e.g. some
    virtualized/network storage acknowledges fsync without a real durable
    flush). Treat fsync here as "reduces the durability window," not as an
    unconditional crash-safety proof.
  * **Directory-entry durability**: this implementation does not fsync the
    containing DIRECTORY's file descriptor after the rename, which a
    maximally paranoid crash-safety design would also do (to guarantee the
    renamed directory entry itself survives a concurrent power loss, not
    just the file's data). That refinement is out of scope for this pass.
  * **Scope**: this guarantee applies ONLY to `write_file!/3`'s real
    `:written` outcome. `:dry_run` still performs zero I/O (unchanged).
    `:unchanged`/`:skipped_exists`/`:skipped_match` never write, so there is
    nothing to make atomic. `inject_content!/5` (existing-file splice) and
    `eval_code!/2` (in-memory eval, no disk write) are explicitly OUT OF
    SCOPE for this guarantee -- they still use a direct `File.write!/2` (or,
    for eval, no write at all).

# `inject_outcome`

```elixir
@type inject_outcome() :: :injected | :unchanged
```

Outcome of a guarded injection into an EXISTING file:
- `:injected` -- the target existed, the anchor matched exactly one line,
  and `content` was spliced in at the requested position.
- `:unchanged` -- `content` was already present immediately at the target
  anchor position (idempotent no-op; safe to re-run).

# `outcome`

```elixir
@type outcome() :: :written | :unchanged | :skipped_exists | :skipped_match
```

Outcome of a guarded write:
- `:written` -- the file did not exist (or existed with differing content
  and no skip guard matched), and was written.
- `:unchanged` -- the file already existed with byte-identical content;
  write skipped (idempotent no-op, always checked, no opt-in flag).
- `:skipped_exists` -- `unless_exists: true` and the target already existed
  (regardless of content).
- `:skipped_match` -- `skip_if: pattern` and the existing file's content
  matched that substring/regex.

# `eval_code!`

```elixir
@spec eval_code!(
  String.t(),
  keyword()
) :: {:ok, term()}
```

Evaluates `code` (a rendered template body, real Elixir source) in-process
via `Code.eval_string/2`, using `bindings` -- the exact same keyword list
already built for `GgenIgniter.Render.render/2`'s EEx evaluation, so eval'd
code can reference `module_name`/`package_name`/etc. exactly like an EEx
template body can. Backs `mode: eval` templates (see
`GgenIgniter.Frontmatter.split_template/1` and
`Mix.Tasks.GgenIgniter.Sync`'s `## Execution mode` docs): the rendered
content is never written to disk at all under this mode.

This is a deliberate, disclosed arbitrary-code-execution capability --
ontology/RDF-driven data becomes literally-executed Elixir code under
`mode: eval`. That is the point of this actuation mode, not an oversight:
templates are trusted input, the same trust boundary an EEx template body
already is today (an EEx template can already run arbitrary Elixir inside
`<%= %>` during rendering).

Returns `{:ok, value}`, the real return value of the evaluated code (the
same value `Code.eval_string/2` itself returns, unwrapped from its
`{value, bindings}` pair -- the post-eval bindings are discarded since
nothing downstream consumes them in this pass). Compile/syntax errors are
caught and re-raised as a clear `RuntimeError` naming the real failure,
never a raw `CompileError`/`SyntaxError`/`TokenMissingError` struct
surfacing uncaught.

## The `igniter:` binding contract (`GgenIgniter.Reactors.ReconcileReactor`
callers only)

`GgenIgniter.Reactors.ReconcileReactor`'s `:actuate` step adds a real,
live `igniter:` entry to `bindings` for every `mode: eval` target it
actuates (see that module's `actuate_eval_sequential/2`/`actuate_eval_one/3`)
-- a genuine `%Igniter{}` (built fresh via `Igniter.new/0` for the first
`:eval` target in a run, or the PREVIOUS `:eval` target's own returned
`%Igniter{}` for every target after it) that the eval'd body can drive
real `Igniter.Project.*`/`Igniter.Code.*` codemods against. If the eval'd
code's own last expression returns an `%Igniter{}` (e.g. via
`Igniter.Project.Module.create_module/3`), that value becomes the
accumulator the NEXT `:eval` target sees -- so N `mode: eval` targets
across a `--targets`/`--for-each` row set compose their Igniter codemods
into ONE final `%Igniter{}`, in row order. Any other return value (every
pre-existing, non-Igniter `mode: eval` template) leaves the accumulator
unchanged for the next target -- zero behavior change for the common
case, and this function itself needs no code change to support it: `code`
simply sees `igniter` as an ordinary local variable, like any other
binding.

**Real, disclosed trade-off**: because this accumulation requires each
`:eval` target to see the previous one's real result, `ReconcileReactor`
runs `:eval` targets SEQUENTIALLY relative to each other (never
concurrently with one another, though still concurrently with the
`:create`/`:replace`/`:inject` batch) -- `Task.async_stream/3`'s parallel
items structurally cannot see each other's return values, so true
concurrent `:eval` targets and real cross-target `%Igniter{}` composition
are mutually exclusive; this module picks composition. Callers outside
`ReconcileReactor` (there are none today) get no `igniter:` binding at
all and no accumulation semantics -- this contract is specific to that
one caller, not a general property of `eval_code!/2` itself.

## Examples

    iex> GgenIgniter.Actuate.eval_code!("1 + 1", [])
    {:ok, 2}

    iex> GgenIgniter.Actuate.eval_code!("x + y", x: 1, y: 2)
    {:ok, 3}

# `inject_content!`

```elixir
@spec inject_content!(
  String.t(),
  String.t() | Regex.t() | nil,
  String.t(),
  :before | :after | :at_line,
  keyword()
) :: {:ok, inject_outcome()}
```

Injects `content` into the EXISTING file at `path`, anchored on `marker`
(a literal `String.t()` or a `Regex.t()` matched against each line), modeled
on the real Rust ggen's `inject_into`/marker-selection semantics in
`ggen-engine/src/write.rs` (`FM-WRITE-003`/`FM-WRITE-004` fail-closed gates),
scoped down to this module's needs: single literal-or-regex anchor, first
(and only permitted) occurrence, no `backup`/`freeze`/checksum machinery.

## Modes (`insert_mode`)

  * `:before` -- insert `content` as new line(s) immediately before the
    matched line.
  * `:after` -- insert `content` as new line(s) immediately after the
    matched line.
  * `:at_line` -- insert `content` at a specific 1-based line number
    (`opts[:line]`, required for this mode). `marker` is ignored.

## Fail-closed gates (in order, mirroring `ggen-engine/src/write.rs`)

  1. Target file does not exist -> raise (`FM-WRITE-003` equivalent).
     Injection is not a substitute for creation; use `write_new_file!/2` or
     `write_file!/3` to create the file first.
  2. `:before`/`:after` marker matches zero lines, or matches more than one
     line (ambiguous) -> raise (`FM-WRITE-004` equivalent). A best-effort
     partial match is never taken.
  3. `:at_line` out of range (`< 1` or `> line_count + 1`) -> raise.

## Idempotency

If `content` is already present immediately at the resolved insertion point
(i.e. the lines that would be spliced in are already there, right where
this call would put them), the write is skipped and `{:ok, :unchanged}` is
returned -- re-running the same injection never duplicates the block.

## Options

  * `:line` (integer, required when `insert_mode: :at_line`) -- 1-based
    target line number.
  * `:dry_run` (boolean, default `false`) -- run every real fail-closed gate
    (target-exists check, anchor uniqueness, `:at_line` range) and the real
    idempotency check against the file's ACTUAL current content, computing
    the same `inject_outcome()` a real call would produce, but never call
    `File.write!/2`. Mirrors `write_file!/3`'s own `:dry_run` option so
    `mix ggen_igniter.sync --dry-run` can preview an injection honestly
    (a real anchor-resolution failure still raises under `:dry_run` -- a
    dry run previews a real decision, it does not suppress a real error).

## Examples

    # anchor on a literal marker line, insert after it
    Actuate.inject_content!(path, "# ggen:slot", "new_line()", :after)

    # anchor on a regex, insert before the unique match
    Actuate.inject_content!(path, ~r/^\s*# GGEN:SLOT\s*$/, "generated", :before)

    # insert at an explicit 1-based line number
    Actuate.inject_content!(path, nil, "zero", :at_line, line: 1)

    # preview only -- computes the real outcome, touches nothing
    Actuate.inject_content!(path, "# ggen:slot", "new_line()", :after, dry_run: true)

# `write_file!`

```elixir
@spec write_file!(String.t(), String.t(), keyword()) :: {:ok, outcome()}
```

Writes `content` to `path`, creating parent directories as needed, applying
write-safety guards in this decision order (first match wins), mirroring
the real Rust ggen's `plan_write` in `ggen-engine/src/write.rs`:

  1. `unless_exists: true` && target exists -> `{:ok, :skipped_exists}`
  2. `skip_if: pattern` && target exists && content matches -> `{:ok, :skipped_match}`
  3. target exists && content byte-identical to `content` -> `{:ok, :unchanged}`
     (unconditional -- no opt-in flag, applies every call)
  4. otherwise -> file is written -> `{:ok, :written}`

## Options

  * `:unless_exists` (boolean, default `false`) -- skip unconditionally if
    the target already exists, regardless of its content.
  * `:skip_if` (`String.t()` or `Regex.t()`, default `nil`) -- skip if the
    target already exists AND its content contains this substring or
    matches this regex.
  * `:dry_run` (boolean, default `false`) -- compute and return the same
    `outcome()` that a real call would produce, but never touch the
    filesystem: no `File.mkdir_p!/1`, no `File.write!/2`. Used by
    `mix ggen_igniter.sync --dry-run` to preview the decision table above
    with zero actual writes.

# `write_new_file!`

```elixir
@spec write_new_file!(String.t(), String.t()) :: :ok
```

Writes `content` to `path`, creating parent directories as needed.

---

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