GgenIgniter.Actuate (ggen_igniter v26.9.8)

Copy Markdown View Source

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).

Summary

Types

Outcome of a guarded injection into an EXISTING file

Outcome of a guarded write

Functions

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.

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.

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

Writes content to path, creating parent directories as needed.

Types

inject_outcome()

@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()

@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.

Functions

eval_code!(code, bindings)

@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!(path, marker, content, insert_mode, opts \\ [])

@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!(path, content, opts \\ [])

@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!(path, content)

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

Writes content to path, creating parent directories as needed.