defmodule Localize.Inputs.Date.Validator do @moduledoc """ Server-side validation for parsed date and date-range values. Pure Elixir, no Ecto dependency. The Ecto changeset bridge is in `Localize.Inputs.Date.Changeset`. """ alias Localize.Inputs.ValidationError @doc """ Validates a parsed `t:Date.t/0` against bounds, weekday restrictions, and required-ness. ### Arguments * `value` is a `t:Date.t/0` or `nil`. * `options` is a keyword list of options. ### Options * `:required` — when `true`, `nil` is rejected. * `:min` — minimum allowed date (`Date` or ISO-8601 string). * `:max` — maximum allowed date. * `:not_weekend` — when `true`, rejects Saturday and Sunday. To customise which weekdays count as "weekend" per locale, pass `:weekend_days` as a list of 1..7 (1 = Monday). * `:on_or_after` — alias for `:min`. When both are given, the stricter (later) bound wins. * `:on_or_before` — alias for `:max`. When both are given, the stricter (earlier) bound wins. * `:business_days_only` — when `true`, rejects any date falling on the locale's weekend (per `:weekend_days` or the default `[6, 7]` = Sat/Sun). Equivalent to `:not_weekend` but with the more business-domain naming. Future extensions may also reject locale-aware public holidays via a `:holidays` option (not yet wired). ### Returns * `:ok` when every check passes. * `{:error, %Localize.Inputs.ValidationError{errors: [{atom(), String.t()}]}}` with one entry per failing check, in the order `:required`, `:min`, `:max`, `:weekend`. ### Examples iex> Localize.Inputs.Date.Validator.validate_date(~D[2026-05-16], min: ~D[2026-01-01]) :ok iex> {:error, %Localize.Inputs.ValidationError{errors: errors}} = ...> Localize.Inputs.Date.Validator.validate_date(~D[2025-01-01], min: ~D[2026-01-01]) iex> Keyword.get(errors, :min) =~ "2026-01-01" true iex> {:error, %Localize.Inputs.ValidationError{errors: errors}} = ...> Localize.Inputs.Date.Validator.validate_date(nil, required: true) iex> errors [{:required, "is required"}] """ @spec validate_date(term(), Keyword.t()) :: :ok | {:error, ValidationError.t()} def validate_date(value, options \\ []) do options = normalize_date_options(options) errors = [] |> check_date_required(value, options) |> check_date_range(value, options) |> check_date_weekend(value, options) |> Enum.reverse() if errors == [], do: :ok, else: {:error, ValidationError.exception(errors: errors)} end # Fold `:on_or_after` / `:on_or_before` aliases into # `:min` / `:max`, preferring the stricter bound when # both are present. `:business_days_only` is a synonym for # `:not_weekend`. defp normalize_date_options(options) do options |> merge_bound(:on_or_after, :min, &keep_later/2) |> merge_bound(:on_or_before, :max, &keep_earlier/2) |> alias_weekend() end defp merge_bound(options, alias_key, canonical_key, merger) do case Keyword.pop(options, alias_key) do {nil, options} -> options {alias_value, options} -> Keyword.update(options, canonical_key, alias_value, fn existing -> merger.(existing, alias_value) end) end end defp keep_later(a, b), do: pick_date(a, b, :gt) defp keep_earlier(a, b), do: pick_date(a, b, :lt) defp pick_date(a, b, preferred_cmp) do with {:ok, a_date} <- to_date(a), {:ok, b_date} <- to_date(b) do if Date.compare(a_date, b_date) == preferred_cmp, do: a, else: b else _ -> a end end defp alias_weekend(options) do case Keyword.pop(options, :business_days_only) do {true, options} -> Keyword.put_new(options, :not_weekend, true) {_, options} -> options end end @doc """ Validates a parsed `t:Date.Range.t/0` against bounds, span, weekend restrictions, and required-ness. ### Arguments * `value` is a `t:Date.Range.t/0` or `nil`. * `options` is a keyword list of options. ### Options * `:required` — when `true`, `nil` is rejected. * `:min`, `:max` — bounds that both endpoints must satisfy. * `:min_span` — minimum span in days (inclusive of both endpoints, so `~D[2026-01-01]..~D[2026-01-03]` has span 3). * `:max_span` — maximum span in days. * `:disallow_inverted` — when `true`, rejects descending ranges. The range parser already rejects inverted ranges by default; this is here for direct validator use. ### Returns * `:ok` when every check passes. * `{:error, ValidationError.t()}` with errors keyed by `:required`, `:min`, `:max`, `:min_span`, `:max_span`, `:inverted`. ### Examples iex> range = Date.range(~D[2026-05-01], ~D[2026-05-07]) iex> Localize.Inputs.Date.Validator.validate_date_range(range, min_span: 5, max_span: 10) :ok iex> range = Date.range(~D[2026-05-01], ~D[2026-05-03]) iex> {:error, %Localize.Inputs.ValidationError{errors: errors}} = ...> Localize.Inputs.Date.Validator.validate_date_range(range, min_span: 5) iex> Keyword.get(errors, :min_span) =~ "5" true """ @spec validate_date_range(term(), Keyword.t()) :: :ok | {:error, ValidationError.t()} def validate_date_range(value, options \\ []) do errors = [] |> check_range_required(value, options) |> check_range_inversion(value, options) |> check_range_endpoints(value, options) |> check_range_span(value, options) |> Enum.reverse() if errors == [], do: :ok, else: {:error, ValidationError.exception(errors: errors)} end # ── Date checks ───────────────────────────────────────────── defp check_date_required(errors, nil, options) do if Keyword.get(options, :required, false) do [{:required, "is required"} | errors] else errors end end defp check_date_required(errors, _value, _options), do: errors defp check_date_range(errors, nil, _options), do: errors defp check_date_range(errors, %Date{} = value, options) do errors |> maybe_check_date_min(value, Keyword.get(options, :min)) |> maybe_check_date_max(value, Keyword.get(options, :max)) end defp check_date_range(errors, _, _), do: errors defp maybe_check_date_min(errors, _value, nil), do: errors defp maybe_check_date_min(errors, value, min) do case to_date(min) do {:ok, min_date} -> if Date.compare(value, min_date) == :lt do [{:min, "must be on or after #{Date.to_iso8601(min_date)}"} | errors] else errors end :error -> errors end end defp maybe_check_date_max(errors, _value, nil), do: errors defp maybe_check_date_max(errors, value, max) do case to_date(max) do {:ok, max_date} -> if Date.compare(value, max_date) == :gt do [{:max, "must be on or before #{Date.to_iso8601(max_date)}"} | errors] else errors end :error -> errors end end defp check_date_weekend(errors, nil, _options), do: errors defp check_date_weekend(errors, %Date{} = value, options) do if Keyword.get(options, :not_weekend, false) do weekend = Keyword.get(options, :weekend_days, [6, 7]) if Date.day_of_week(value) in weekend do [{:weekend, "must not fall on a weekend"} | errors] else errors end else errors end end defp check_date_weekend(errors, _, _), do: errors # ── Range checks ──────────────────────────────────────────── defp check_range_required(errors, nil, options) do if Keyword.get(options, :required, false) do [{:required, "is required"} | errors] else errors end end defp check_range_required(errors, _value, _options), do: errors defp check_range_inversion(errors, nil, _options), do: errors defp check_range_inversion(errors, %Date.Range{step: step}, options) do disallow = Keyword.get(options, :disallow_inverted, false) if disallow and step < 0 do [{:inverted, "range start must be on or before range end"} | errors] else errors end end defp check_range_inversion(errors, _, _), do: errors defp check_range_endpoints(errors, nil, _options), do: errors defp check_range_endpoints(errors, %Date.Range{first: first, last: last}, options) do errors |> maybe_check_date_min(first, Keyword.get(options, :min)) |> maybe_check_date_max(last, Keyword.get(options, :max)) end defp check_range_endpoints(errors, _, _), do: errors defp check_range_span(errors, nil, _options), do: errors defp check_range_span(errors, %Date.Range{first: first, last: last}, options) do span = abs(Date.diff(last, first)) + 1 errors |> maybe_check_min_span(span, Keyword.get(options, :min_span)) |> maybe_check_max_span(span, Keyword.get(options, :max_span)) end defp check_range_span(errors, _, _), do: errors defp maybe_check_min_span(errors, _span, nil), do: errors defp maybe_check_min_span(errors, span, min_span) when is_integer(min_span) do if span < min_span do [{:min_span, "must span at least #{min_span} days"} | errors] else errors end end defp maybe_check_max_span(errors, _span, nil), do: errors defp maybe_check_max_span(errors, span, max_span) when is_integer(max_span) do if span > max_span do [{:max_span, "must span at most #{max_span} days"} | errors] else errors end end # Accept Date structs, ISO-8601 strings, or anything we can # coerce. Returns `:error` for nil/garbage rather than # raising so the caller can silently skip the bound. defp to_date(%Date{} = date), do: {:ok, date} defp to_date(string) when is_binary(string) do case Date.from_iso8601(string) do {:ok, date} -> {:ok, date} _ -> :error end end defp to_date(_), do: :error end