-module(rebar3_otter__config).
-moduledoc """
Validation and normalization of the `otter_crates` rebar3 config key.
`otter_crates` is read per application: declare it in each app's own
`rebar.config`, and `path` is interpreted relative to that app's
directory. The compiled artifact is installed into the declaring app's
`priv/native/`, so umbrella projects place each NIF where
`code:priv_dir/1` for that app will find it.
Both the compile and clean providers go through `validate/1` (passing the
app's raw `otter_crates` value) so they agree on the schema. Errors are
tagged tuples that `format_error/1` renders into human-readable strings.
Schema:
```
{otter_crates, [
#{
name := string() | binary(), % required
path := string() | binary(), % required, app-relative
mode => release | debug, % default release
features => [string() | binary()],% default []
target => string() | binary() % default undefined
}
]}.
```
String-typed values accept a string or binary, normalized to a string.
Unknown map keys are rejected.
""".
-export([validate/1, format_error/1]).
-export_type([crate/0]).
%%------------------------------------------------------------------------------
-type crate() :: #{
name := string(),
path := string(),
mode := release | debug,
features := [string()],
target := string() | undefined
}.
-define(KNOWN_KEYS, [name, path, mode, features, target]).
%%%=============================================================================
%%% Public
-spec validate(term()) -> {ok, [crate()]} | {error, term()}.
validate(Raw) ->
case is_list(Raw) of
false ->
{error, {otter_crates_not_a_list, Raw}};
true ->
validate_entries(Raw, [])
end.
-spec format_error(term()) -> string() | iolist().
format_error({otter_crates_not_a_list, Value}) ->
io_lib:format("otter_crates must be a list, got: ~p", [Value]);
format_error({crate_entry_not_a_map, Value}) ->
io_lib:format("each otter_crates entry must be a map, got: ~p", [Value]);
format_error({missing_field, Field, Identity}) ->
io_lib:format("crate ~s is missing required field '~s'",
[Identity, Field]);
format_error({invalid_field_type, Field, Expected, Value, Identity}) ->
io_lib:format("crate ~s field '~s' must be ~s, got: ~p",
[Identity, Field, Expected, Value]);
format_error({invalid_mode, Value, Identity}) ->
io_lib:format("crate ~s field 'mode' must be 'release' or 'debug', got: ~p",
[Identity, Value]);
format_error({invalid_feature, Value, Identity}) ->
io_lib:format("crate ~s field 'features' must be a list of strings or "
"binaries, got element: ~p", [Identity, Value]);
format_error({unknown_key, Key, Identity}) ->
io_lib:format("crate ~s has unknown key '~s'", [Identity, Key]);
format_error(Other) ->
io_lib:format("~p", [Other]).
%%%=============================================================================
%%% Private
-spec validate_entries([term()], [crate()]) -> {ok, [crate()]} | {error, term()}.
validate_entries([], Acc) ->
{ok, lists:reverse(Acc)};
validate_entries([Entry | Rest], Acc) ->
case validate_entry(Entry) of
{ok, Crate} -> validate_entries(Rest, [Crate | Acc]);
{error, _} = Err -> Err
end.
-spec validate_entry(term()) -> {ok, crate()} | {error, term()}.
validate_entry(Entry) when not is_map(Entry) ->
{error, {crate_entry_not_a_map, Entry}};
validate_entry(Entry) ->
Identity = identify(Entry),
case check_unknown_keys(Entry, Identity) of
{error, _} = Err1 -> Err1;
ok ->
case require_field(name, Entry, Identity) of
{error, _} = Err2 -> Err2;
ok ->
case require_field(path, Entry, Identity) of
{error, _} = Err3 -> Err3;
ok -> normalize(Entry, Identity)
end
end
end.
-spec check_unknown_keys(map(), iolist()) -> ok | {error, term()}.
check_unknown_keys(Entry, Identity) ->
Keys = maps:keys(Entry),
case [K || K <- Keys, not lists:member(K, ?KNOWN_KEYS)] of
[] -> ok;
[Bad | _] -> {error, {unknown_key, Bad, Identity}}
end.
-spec require_field(atom(), map(), iolist()) -> ok | {error, term()}.
require_field(Field, Entry, Identity) ->
case maps:is_key(Field, Entry) of
true -> ok;
false -> {error, {missing_field, Field, Identity}}
end.
%% Validate and normalize each field, accumulating the converted values.
%% First field to fail short-circuits the whole entry.
-spec normalize(map(), iolist()) -> {ok, crate()} | {error, term()}.
normalize(Entry, Identity) ->
Fields = [
{name, fun normalize_name/2, fun(Entry1) -> maps:get(name, Entry1) end},
{path, fun normalize_path/2, fun(Entry1) -> maps:get(path, Entry1) end},
{mode, fun normalize_mode/2, fun(Entry1) -> maps:get(mode, Entry1, release) end},
{features, fun normalize_features/2, fun(Entry1) -> maps:get(features, Entry1, []) end},
{target, fun normalize_target/2, fun(Entry1) -> maps:get(target, Entry1, undefined) end}
],
normalize_fields(Fields, Entry, Identity, #{}).
-spec normalize_fields(
[{atom(), fun(), fun()}], map(), iolist(), map()) ->
{ok, crate()} | {error, term()}.
normalize_fields([], _Entry, _Identity, Acc) ->
{ok, Acc};
normalize_fields([{Key, Normalizer, Reader} | Rest], Entry, Identity, Acc) ->
case Normalizer(Reader(Entry), Identity) of
{ok, Value} -> normalize_fields(Rest, Entry, Identity, Acc#{Key => Value});
{error, _} = Err -> Err
end.
-spec normalize_name(term(), iolist()) -> {ok, string()} | {error, term()}.
normalize_name(V, _Identity) when is_list(V); is_binary(V) ->
{ok, to_str(V)};
normalize_name(V, Identity) ->
{error, {invalid_field_type, name, "a string or binary", V, Identity}}.
-spec normalize_path(term(), iolist()) -> {ok, string()} | {error, term()}.
normalize_path(V, _Identity) when is_list(V); is_binary(V) ->
{ok, to_str(V)};
normalize_path(V, Identity) ->
{error, {invalid_field_type, path, "a string or binary", V, Identity}}.
-spec normalize_mode(term(), iolist()) -> {ok, release | debug} | {error, term()}.
normalize_mode(release, _Identity) -> {ok, release};
normalize_mode(debug, _Identity) -> {ok, debug};
normalize_mode(V, Identity) -> {error, {invalid_mode, V, Identity}}.
-spec normalize_features(term(), iolist()) -> {ok, [string()]} | {error, term()}.
normalize_features(L, Identity) when is_list(L) ->
case [F || F <- L, not is_feature_elem(F)] of
[] -> {ok, [to_str(F) || F <- L]};
[Bad | _] -> {error, {invalid_feature, Bad, Identity}}
end;
normalize_features(V, Identity) ->
{error, {invalid_field_type, features, "a list", V, Identity}}.
-spec is_feature_elem(term()) -> boolean().
is_feature_elem(V) -> is_list(V) orelse is_binary(V).
-spec normalize_target(term(), iolist()) ->
{ok, string() | undefined} | {error, term()}.
normalize_target(undefined, _Identity) ->
{ok, undefined};
normalize_target(V, _Identity) when is_list(V); is_binary(V) ->
{ok, to_str(V)};
normalize_target(V, Identity) ->
{error, {invalid_field_type, target, "a string or binary", V, Identity}}.
%%------------------------------------------------------------------------------
%% Helpers
%% A human-readable identity for an entry, used in error messages. Falls back
%% to the raw entry shape when no name is available, so the message is still
%% specific enough to locate the offending config.
-spec identify(map()) -> iolist().
identify(Entry) ->
case maps:get(name, Entry, undefined) of
undefined ->
io_lib:format("~p", [Entry]);
Name when is_atom(Name) ->
io_lib:format("'~s'", [atom_to_list(Name)]);
Name when is_list(Name) ->
io_lib:format("'~s'", [Name]);
Name when is_binary(Name) ->
io_lib:format("'~s'", [binary_to_list(Name)]);
Name ->
io_lib:format("~p", [Name])
end.
-spec to_str(string() | binary()) -> string().
to_str(V) when is_list(V) -> V;
to_str(V) when is_binary(V) -> binary_to_list(V).