defmodule RustQ.Generated do
@moduledoc """
File sync helpers for RustQ-generated sources.
Most projects should use `rustq.exs` with `mix rustq.gen`. Use this module
directly when a project already has its own Mix task and only needs RustQ's
write/check behavior.
RustQ.Generated.sync!(:helpers,
path: "native/my_nif/src/generated_helpers.rs",
build: fn -> render_helpers() end
)
Pass `check: true` to compare without writing, which is useful in CI.
"""
defmodule StaleError do
@moduledoc """
Raised when generated files differ from the contents produced by the manifest.
"""
defexception [:paths, command: "mix rustq.gen"]
@impl true
def message(%{paths: paths, command: command}) do
paths = List.wrap(paths)
stale =
case paths do
[path] -> "generated file is stale: #{path}"
paths -> "generated files are stale:\n" <> Enum.map_join(paths, "\n", &" - #{&1}")
end
"#{stale}\nRun: #{command}"
end
end
@type target :: {atom() | String.t(), keyword()}
@spec load_manifest!(Path.t()) :: [target()]
def load_manifest!(path \\ "rustq.exs") do
unless File.exists?(path) do
raise ArgumentError, "RustQ manifest not found: #{path}"
end
{manifest, _binding} = Code.eval_file(path)
normalize_manifest!(manifest)
end
@spec sync_all!([target()], keyword()) :: :ok
def sync_all!(targets, opts \\ []) do
only = opts |> Keyword.get(:only, []) |> Enum.map(&to_string/1)
targets =
Enum.filter(targets, fn {name, _target} -> only == [] or to_string(name) in only end)
if Keyword.get(opts, :check, false) do
check_all!(targets, opts)
else
Enum.each(targets, fn {name, target} -> sync!(name, target, opts) end)
end
:ok
end
@spec sync!(atom() | String.t(), keyword(), keyword()) :: :ok
def sync!(name, target, opts \\ []) do
path = Keyword.fetch!(target, :path)
contents = target |> build!() |> normalize_newlines()
if Keyword.get(opts, :check, false) do
check!(path, contents, command: Keyword.get(opts, :command, "mix rustq.gen"))
emit(opts, "Fresh #{name}: #{Path.relative_to_cwd(path)}")
else
write!(path, contents)
emit(opts, "Generated #{name}: #{Path.relative_to_cwd(path)}")
end
:ok
end
@spec write!(Path.t(), iodata()) :: :ok
def write!(path, contents) do
File.mkdir_p!(Path.dirname(path))
File.write!(path, normalize_newlines(contents))
end
@spec check!(Path.t(), iodata(), keyword()) :: :ok
def check!(path, expected, opts \\ []) do
expected = normalize_newlines(expected)
actual = if File.exists?(path), do: File.read!(path) |> normalize_newlines(), else: nil
if actual == expected do
:ok
else
raise StaleError,
paths: [Path.relative_to_cwd(path)],
command: Keyword.get(opts, :command, "mix rustq.gen")
end
end
defp check_all!(targets, opts) do
stale_paths =
targets
|> Enum.reject(fn {name, target} ->
path = Keyword.fetch!(target, :path)
contents = target |> build!() |> normalize_newlines()
if fresh?(path, contents) do
emit(opts, "Fresh #{name}: #{Path.relative_to_cwd(path)}")
true
else
false
end
end)
|> Enum.map(fn {_name, target} ->
target |> Keyword.fetch!(:path) |> Path.relative_to_cwd()
end)
if stale_paths != [] do
raise StaleError,
paths: stale_paths,
command: Keyword.get(opts, :command, "mix rustq.gen")
end
end
defp fresh?(path, expected) do
File.exists?(path) and File.read!(path) |> normalize_newlines() == expected
end
defp normalize_manifest!(manifest) when is_list(manifest) do
manifest
|> Keyword.fetch!(:generated)
|> Enum.map(fn
{name, target} when is_list(target) -> {name, target}
other -> raise ArgumentError, "invalid RustQ generated target: #{inspect(other)}"
end)
end
defp build!(target) do
cond do
build = Keyword.get(target, :build) -> build.()
Keyword.has_key?(target, :content) -> Keyword.fetch!(target, :content)
true -> raise ArgumentError, "generated target needs :build or :content"
end
end
defp normalize_newlines(iodata) do
iodata
|> IO.iodata_to_binary()
|> String.replace("\r\n", "\n")
end
defp emit(opts, message) do
case Keyword.get(opts, :shell) do
nil -> :ok
shell -> shell.info(message)
end
end
end