defmodule ImageBriefValidator do @moduledoc """ Validates the structure of an image-generation brief before it is passed to a rendering workflow. The validator is provider-independent and performs no network requests. """ @valid_styles ~w(photographic illustration flat_vector product_shot poster) @required_keys [:prompt, :width, :height] @doc """ Validates a brief and returns either the original brief or all detected errors. ## Examples iex> ImageBriefValidator.validate(%{prompt: "A studio product photo", width: 1200, height: 800}) {:ok, %{prompt: "A studio product photo", width: 1200, height: 800}} iex> ImageBriefValidator.validate(%{prompt: "", width: 0, height: 800}) {:error, [prompt: :must_be_present, width: :must_be_a_positive_integer]} """ @spec validate(map()) :: {:ok, map()} | {:error, keyword()} def validate(%{} = brief) do case validate_all(brief) do [] -> {:ok, brief} errors -> {:error, errors} end end def validate(_brief), do: {:error, brief: :must_be_a_map} @doc """ Returns all structural errors as a keyword list. String and atom keys are both accepted. Optional `style` values are limited to the documented presets, and `references` must be a list of non-empty strings. """ @spec validate_all(map()) :: keyword() def validate_all(%{} = brief) do normalized = normalize_keys(brief) [] |> check_required(normalized) |> check_prompt(normalized) |> check_dimension(:width, normalized) |> check_dimension(:height, normalized) |> check_style(normalized) |> check_references(normalized) |> Enum.reverse() end @doc """ Returns the reduced aspect ratio for valid positive integer dimensions. iex> ImageBriefValidator.aspect_ratio(1920, 1080) {:ok, "16:9"} """ @spec aspect_ratio(integer(), integer()) :: {:ok, String.t()} | {:error, atom()} def aspect_ratio(width, height) when is_integer(width) and width > 0 and is_integer(height) and height > 0 do divisor = Integer.gcd(width, height) {:ok, "#{div(width, divisor)}:#{div(height, divisor)}"} end def aspect_ratio(_width, _height), do: {:error, :invalid_dimensions} defp normalize_keys(brief) do Map.new(brief, fn {key, value} when is_binary(key) -> normalized_key = case key do "prompt" -> :prompt "width" -> :width "height" -> :height "style" -> :style "references" -> :references other -> other end {normalized_key, value} pair -> pair end) end defp check_required(errors, brief) do Enum.reduce(@required_keys, errors, fn key, acc -> if Map.has_key?(brief, key), do: acc, else: [{key, :is_required} | acc] end) end defp check_prompt(errors, %{prompt: prompt}) when is_binary(prompt) do if String.trim(prompt) == "", do: [{:prompt, :must_be_present} | errors], else: errors end defp check_prompt(errors, %{prompt: _}), do: [{:prompt, :must_be_a_string} | errors] defp check_prompt(errors, _brief), do: errors defp check_dimension(errors, key, brief) do case Map.fetch(brief, key) do {:ok, value} when is_integer(value) and value > 0 -> errors {:ok, _value} -> [{key, :must_be_a_positive_integer} | errors] :error -> errors end end defp check_style(errors, %{style: style}) when style in @valid_styles, do: errors defp check_style(errors, %{style: nil}), do: errors defp check_style(errors, %{style: _style}), do: [{:style, :is_not_supported} | errors] defp check_style(errors, _brief), do: errors defp check_references(errors, %{references: references}) when is_list(references) do valid? = Enum.all?(references, fn reference -> is_binary(reference) and String.trim(reference) != "" end) if valid?, do: errors, else: [{:references, :must_contain_non_empty_strings} | errors] end defp check_references(errors, %{references: _references}), do: [{:references, :must_be_a_list} | errors] defp check_references(errors, _brief), do: errors end