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, noinject:) -- guarded whole-file write/no-op/skip, the default for amode: filetemplate.inject_content!/5(mode: file, frontmatterinject: true) -- guarded splice into an EXISTING file, anchored on the template'sbefore:/after:/at_line:frontmatter field.Mix.Tasks.GgenIgniter.Syncresolves the frontmatter'sbefore/after(GgenIgniter.Frontmatter.MatchSpec.t()) into this function's realmarkerarg (String.t() | Regex.t() | nil) -- seeMix.Tasks.GgenIgniter.Sync's privatematch_spec_to_marker!/2for the literal-vs-structuredMatchRuleconversion, including whichmatcher/scope/occurrence/trimcombinations 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:
- 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). File.write!/2the full content to that temp file.- Best-effort
fsyncthe 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. File.rename!/2the temp file onto the finalpath.
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!/2on Windows (MoveFileEx-backed) is not guaranteed atomic when the destination already exists on all Windows filesystem/OS version combinations the way POSIXrename(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. Ifpathlives on an NFS mount, this guarantee weakens to "best effort," not "atomic." - fsync durability: step 3's
:file.sync/1call 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:writtenoutcome.:dry_runstill performs zero I/O (unchanged).:unchanged/:skipped_exists/:skipped_matchnever write, so there is nothing to make atomic.inject_content!/5(existing-file splice) andeval_code!/2(in-memory eval, no disk write) are explicitly OUT OF SCOPE for this guarantee -- they still use a directFile.write!/2(or, for eval, no write at all).
Summary
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
@type inject_outcome() :: :injected | :unchanged
Outcome of a guarded injection into an EXISTING file:
:injected-- the target existed, the anchor matched exactly one line, andcontentwas spliced in at the requested position.:unchanged--contentwas already present immediately at the target anchor position (idempotent no-op; safe to re-run).
@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: trueand the target already existed (regardless of content).:skipped_match--skip_if: patternand the existing file's content matched that substring/regex.
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.
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}
@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-- insertcontentas new line(s) immediately before the matched line.:after-- insertcontentas new line(s) immediately after the matched line.:at_line-- insertcontentat a specific 1-based line number (opts[:line], required for this mode).markeris ignored.
Fail-closed gates (in order, mirroring ggen-engine/src/write.rs)
- Target file does not exist -> raise (
FM-WRITE-003equivalent). Injection is not a substitute for creation; usewrite_new_file!/2orwrite_file!/3to create the file first. :before/:aftermarker matches zero lines, or matches more than one line (ambiguous) -> raise (FM-WRITE-004equivalent). A best-effort partial match is never taken.:at_lineout of range (< 1or> 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 wheninsert_mode: :at_line) -- 1-based target line number.:dry_run(boolean, defaultfalse) -- run every real fail-closed gate (target-exists check, anchor uniqueness,:at_linerange) and the real idempotency check against the file's ACTUAL current content, computing the sameinject_outcome()a real call would produce, but never callFile.write!/2. Mirrorswrite_file!/3's own:dry_runoption somix ggen_igniter.sync --dry-runcan 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)
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:
unless_exists: true&& target exists ->{:ok, :skipped_exists}skip_if: pattern&& target exists && content matches ->{:ok, :skipped_match}- target exists && content byte-identical to
content->{:ok, :unchanged}(unconditional -- no opt-in flag, applies every call) - otherwise -> file is written ->
{:ok, :written}
Options
:unless_exists(boolean, defaultfalse) -- skip unconditionally if the target already exists, regardless of its content.:skip_if(String.t()orRegex.t(), defaultnil) -- skip if the target already exists AND its content contains this substring or matches this regex.:dry_run(boolean, defaultfalse) -- compute and return the sameoutcome()that a real call would produce, but never touch the filesystem: noFile.mkdir_p!/1, noFile.write!/2. Used bymix ggen_igniter.sync --dry-runto preview the decision table above with zero actual writes.
Writes content to path, creating parent directories as needed.