Packages

fnord

0.9.26

AI code archaeology

Current section

Files

Jump to
fnord lib settings validation.ex
Raw

lib/settings/validation.ex

defmodule Settings.Validation do
@moduledoc """
Reads and normalizes project-scoped validation settings.
Validation rules live under a project's `validation` key in `settings.json`.
Each rule maps one or more project-relative path globs to a command string
that should be executed when matching files change.
"""
@type rule :: %{
path_globs: [String.t()],
command: String.t()
}
@doc """
Returns the normalized validation rules for the currently selected project.
"""
@spec list() :: [rule()]
def list() do
case Settings.get_selected_project() do
{:ok, project_name} -> list(project_name)
{:error, :project_not_set} -> []
end
end
@doc """
Returns the normalized validation rules for the named project.
"""
@spec list(String.t()) :: [rule()]
def list(project_name) when is_binary(project_name) do
Settings.new()
|> Settings.get_project_data(project_name)
|> get_rules()
|> normalize_rules()
end
@doc """
Returns true when the current project has at least one valid validation rule.
"""
@spec configured?() :: boolean
def configured?() do
list() != []
end
@doc """
Returns true when the named project has at least one valid validation rule.
"""
@spec configured?(String.t()) :: boolean
def configured?(project_name) when is_binary(project_name) do
list(project_name) != []
end
@doc """
Adds a normalized validation rule to the currently selected project so the
active project's validation command set includes another file-matching rule.
"""
@spec add_rule(String.t(), String.t() | [String.t()]) ::
{:ok, [rule()]} | {:error, :invalid_rule | :project_not_set}
def add_rule(command, path_globs) do
case Settings.get_selected_project() do
{:ok, project_name} -> add_rule(project_name, command, path_globs)
{:error, :project_not_set} -> {:error, :project_not_set}
end
end
@doc """
Adds a normalized validation rule to the named project as part of managing
the project's persisted validation rule set.
"""
@spec add_rule(String.t(), String.t(), String.t() | [String.t()]) ::
{:ok, [rule()]} | {:error, :invalid_rule | :project_not_found}
def add_rule(project_name, command, path_globs)
when is_binary(project_name) and is_binary(command) do
case normalize_candidate_rule(command, path_globs) do
nil ->
{:error, :invalid_rule}
rule ->
update_rules(project_name, fn rules ->
{:ok, rules ++ [rule]}
end)
end
end
@doc """
Removes the validation rule at the given 1-based index from the currently
selected project so the active project's validation rule set no longer
includes that entry.
"""
@spec remove_rule(integer()) ::
{:ok, [rule()]} | {:error, :invalid_index | :project_not_set | :project_not_found}
def remove_rule(index) do
case Settings.get_selected_project() do
{:ok, project_name} -> remove_rule(project_name, index)
{:error, :project_not_set} -> {:error, :project_not_set}
end
end
@doc """
Removes the validation rule at the given 1-based index from the named
project as part of maintaining its persisted validation rules.
"""
@spec remove_rule(String.t(), integer()) ::
{:ok, [rule()]} | {:error, :invalid_index | :project_not_found}
def remove_rule(project_name, index) when is_binary(project_name) and is_integer(index) do
case valid_index?(index) do
false ->
{:error, :invalid_index}
true ->
update_rules(project_name, fn rules ->
remove_rule_at(rules, index)
end)
end
end
@doc """
Clears all validation rules for the currently selected project, resetting the
active project's validation rule set.
"""
@spec clear() :: :ok | {:error, :project_not_found}
def clear() do
case Settings.get_selected_project() do
{:ok, project_name} -> clear(project_name)
{:error, :project_not_set} -> :ok
end
end
@doc """
Clears all validation rules for the named project by replacing its persisted
validation rules with an empty list.
"""
@spec clear(String.t()) :: :ok | {:error, :project_not_found}
def clear(project_name) when is_binary(project_name) do
case update_rules(project_name, fn _rules ->
{:ok, []}
end) do
{:ok, _rules} -> :ok
{:error, :project_not_found} -> {:error, :project_not_found}
end
end
@spec get_rules(map() | nil) :: list()
defp get_rules(nil), do: []
defp get_rules(project_data) when is_map(project_data) do
Map.get(project_data, "validation", [])
end
@spec normalize_rules(list()) :: [rule()]
defp normalize_rules(rules) when is_list(rules) do
rules
|> Enum.map(&normalize_rule/1)
|> Enum.reject(&is_nil/1)
end
@spec normalize_rule(term()) :: rule() | nil
defp normalize_rule(%{"path_globs" => path_globs, "command" => command}) do
with {:ok, normalized_globs} <- normalize_globs(path_globs),
{:ok, normalized_command} <- normalize_command(command) do
%{
path_globs: normalized_globs,
command: normalized_command
}
else
_ -> nil
end
end
defp normalize_rule(_other), do: nil
# Builds a candidate rule from user input. Rejects the entire rule if any
# individual glob is blank after trimming, since that indicates malformed
# input rather than a glob to silently drop.
@spec normalize_candidate_rule(term(), term()) :: rule() | nil
defp normalize_candidate_rule(command, path_globs) when is_list(path_globs) do
has_blank =
Enum.any?(path_globs, fn
g when is_binary(g) -> String.trim(g) == ""
_ -> false
end)
if has_blank do
nil
else
normalize_rule(%{"command" => command, "path_globs" => path_globs})
end
end
defp normalize_candidate_rule(command, path_globs) do
normalize_rule(%{"command" => command, "path_globs" => path_globs})
end
@spec dump_rule(rule()) :: map()
defp dump_rule(%{path_globs: path_globs, command: command}) do
%{
"path_globs" => path_globs,
"command" => command
}
end
@spec put_rules(map(), [rule()]) :: map()
defp put_rules(project_data, rules) when is_map(project_data) and is_list(rules) do
Map.put(project_data, "validation", Enum.map(rules, &dump_rule/1))
end
@spec update_rules(String.t(), ([rule()] -> {:ok, [rule()]} | {:error, term()})) ::
{:ok, [rule()]} | {:error, term()}
defp update_rules(project_name, fun) when is_binary(project_name) and is_function(fun, 1) do
settings = Settings.new()
case Settings.get_project_data(settings, project_name) do
nil ->
{:error, :project_not_found}
_project_data ->
current_rules = list(project_name)
case fun.(current_rules) do
{:ok, updated_rules} ->
settings =
Settings.update(
settings,
"projects",
fn projects ->
current_projects = normalize_projects(projects)
project_data = Map.fetch!(current_projects, project_name)
Map.put(current_projects, project_name, put_rules(project_data, updated_rules))
end,
%{}
)
updated_rules =
settings
|> Settings.get_project_data(project_name)
|> get_rules()
|> normalize_rules()
{:ok, updated_rules}
{:error, reason} ->
{:error, reason}
end
end
end
@spec normalize_projects(term()) :: map()
defp normalize_projects(projects) when is_map(projects), do: projects
defp normalize_projects(_other), do: %{}
@spec remove_rule_at([rule()], integer()) :: {:ok, [rule()]} | {:error, :invalid_index}
defp remove_rule_at(rules, index) do
zero_based_index = index - 1
case zero_based_index < length(rules) do
true -> {:ok, List.delete_at(rules, zero_based_index)}
false -> {:error, :invalid_index}
end
end
@spec valid_index?(term()) :: boolean()
defp valid_index?(index) when is_integer(index) and index > 0, do: true
defp valid_index?(_index), do: false
@spec normalize_globs(term()) :: {:ok, [String.t()]} | :error
defp normalize_globs(globs) when is_list(globs) do
globs
|> Enum.filter(&is_binary/1)
|> Enum.flat_map(&split_glob/1)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> case do
[] -> :error
normalized -> {:ok, normalized}
end
end
defp normalize_globs(glob) when is_binary(glob) do
normalize_globs([glob])
end
defp normalize_globs(_other), do: :error
# Splits a glob string that may contain multiple space-separated patterns
# into individual entries. Uses shell-style tokenization so quoted segments
# with spaces are preserved as a single pattern.
@spec split_glob(String.t()) :: [String.t()]
defp split_glob(glob) do
OptionParser.split(glob)
end
@spec normalize_command(term()) :: {:ok, String.t()} | :error
defp normalize_command(command) when is_binary(command) do
case String.trim(command) do
"" -> :error
normalized -> {:ok, normalized}
end
end
defp normalize_command(_other), do: :error
end