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