Current section

Files

Jump to
mob_dev lib mob_dev plugin manifest.ex
Raw

lib/mob_dev/plugin/manifest.ex

defmodule MobDev.Plugin.Manifest do
@moduledoc """
Reads, validates, and classifies a plugin's `priv/mob_plugin.exs` manifest.
The manifest is data, not code (see `MOB_PLUGINS.md`): a plain Elixir map
describing what a plugin contributes. This module is the single place that
turns that map into a validated, classified description — tier, hot-push
status, activation — that `mix mob.plugins` reports and the compile-time
merge will later consume.
A tier-0 plugin has no manifest at all; `load/1` returns `{:ok, nil}` for
that case, and `tier(nil)` is `0`.
"""
@manifest_path "priv/mob_plugin.exs"
# Spec versions this mob_dev understands. Bumped when MOB_PLUGINS.md makes a
# breaking schema change; old plugins keep validating against old specs.
@supported_spec_versions [1, 2]
@native_sections [
:nifs,
:nifs_generator,
:android,
:ios,
:ui_components,
:ui_components_generator
]
@screen_sections [:screens, :screens_generator, :migrations, :assets]
@subapp_sections [:lifecycle, :settings, :notifications]
@visual_sections [:ui_components, :ui_components_generator]
@tier1_sections [:nifs, :nifs_generator, :android, :ios]
# Sections whose Elixir half can still hot-push even when the plugin has
# native code (the partial case).
@pushable_sections [:screens, :screens_generator, :lifecycle, :migrations]
@doc """
Loads the manifest for a plugin checked out at `plugin_dir`.
Returns `{:ok, nil}` when there is no `priv/mob_plugin.exs` (a tier-0
plugin), `{:ok, map}` when one is present and evaluates to a map, or
`{:error, reason}` when the file is unreadable or doesn't yield a map.
Does not validate field contents — call `validate/1` for that.
"""
@spec load(Path.t()) :: {:ok, map() | nil} | {:error, String.t()}
def load(plugin_dir) do
path = Path.join(plugin_dir, @manifest_path)
if File.exists?(path) do
eval(path)
else
{:ok, nil}
end
end
defp eval(path) do
case Code.eval_file(path) do
{map, _bindings} when is_map(map) ->
{:ok, map}
{other, _bindings} ->
{:error, "#{path} must evaluate to a map, got: #{inspect(other)}"}
end
rescue
e -> {:error, "#{path} failed to evaluate: #{Exception.message(e)}"}
end
@doc """
Validates a manifest map against the spec's required top-level fields.
Returns `{:ok, manifest}` or `{:error, reasons}` with a list of every
problem found (validation never stops at the first error). `nil` (no
manifest) is valid — a tier-0 plugin has nothing to validate.
"""
@spec validate(map() | nil) :: {:ok, map() | nil} | {:error, [String.t()]}
def validate(nil), do: {:ok, nil}
def validate(manifest) when is_map(manifest) do
errors =
[]
|> check_name(manifest)
|> check_mob_version(manifest)
|> check_spec_version(manifest)
case errors do
[] -> {:ok, manifest}
errs -> {:error, Enum.reverse(errs)}
end
end
def validate(other),
do:
{:error, ["manifest must be a map (the priv/mob_plugin.exs data), got: #{inspect(other)}"]}
defp check_name(errors, %{name: name}) when is_atom(name) and not is_nil(name), do: errors
defp check_name(errors, _), do: [":name is required and must be an atom" | errors]
defp check_mob_version(errors, %{mob_version: req}) when is_binary(req) do
case Version.parse_requirement(req) do
{:ok, _} -> errors
:error -> [":mob_version #{inspect(req)} is not a valid version requirement" | errors]
end
end
defp check_mob_version(errors, _),
do: [
":mob_version is required and must be a version requirement string (e.g. \"~> 0.6\")"
| errors
]
defp check_spec_version(errors, %{plugin_spec_version: v}) when is_integer(v) do
if v in @supported_spec_versions do
errors
else
[
"plugin_spec_version #{v} is not supported (this mob_dev knows #{inspect(@supported_spec_versions)})"
| errors
]
end
end
defp check_spec_version(errors, _),
do: [":plugin_spec_version is required and must be an integer" | errors]
@doc """
Classifies the plugin tier (0–4) from which capability sections are present.
Highest matching section wins. `nil` (no manifest) is tier 0. A manifest
with required fields but no capability sections is the tier-1 floor (the
"minimum viable manifest").
"""
@spec tier(map() | nil) :: 0..4
def tier(nil), do: 0
def tier(m) when is_map(m) do
cond do
has_any?(m, @subapp_sections) -> 4
has_any?(m, @screen_sections) -> 3
has_any?(m, @visual_sections) -> 2
has_any?(m, @tier1_sections) -> 1
true -> 1
end
end
@doc """
Whether the plugin's contributions can be hot-pushed without a native rebuild.
Computed from populated sections, not from tier: `true` for pure-Elixir
plugins, `false` for native-only ones (NIFs / components), and `:partial`
when a plugin mixes native code with hot-pushable Elixir (screens, lifecycle).
"""
@spec hot_pushable(map() | nil) :: true | false | :partial
def hot_pushable(nil), do: true
def hot_pushable(m) when is_map(m) do
cond do
not has_any?(m, @native_sections) -> true
has_any?(m, @pushable_sections) -> :partial
true -> false
end
end
defp has_any?(map, keys), do: Enum.any?(keys, &Map.has_key?(map, &1))
end