Value casting and validation for facets (Spec 018).
One implementation, two entry points:
cast/4— the successful path, returning{:ok, operator, value}. The operator comes back because casting can refine it::on a range facet resolves to:between, and on amultiple?: truefacet resolves to:inor stays:eqdepending on whether the literal has commas.validate/4— the same work, discarding the value and returning:ok | {:error, {reason, params}}.
Because they share an implementation, a validation reason can never exist without the cast having rejected the value that produced it, and vice versa — which is what ADR-012 needs to report a known facet's bad value instead of silently degrading it.
The grammar this casts
Locale-invariant throughout, per
ADR-013:
ISO 8601 dates, . as the decimal separator, .. for ranges, , for
lists, canonical preset and enum ids. No locale reaches this module.
- Scalars —
42,1.5,true,2026-07-01,2026-07-01T09:30:00Z, plus relative dates (7d,2w,1m,1y) and named dates (today,yesterday). - Durations —
90s,15m,2h30m,1d, cast to integer seconds. - Ranges —
from..to,..to,from.., or a bare scalar (a single-day range for:date_range; the whole UTC day for:datetime_range), or a preset token. - Lists —
a,b,c, only on amultiple?: truefacet.
Reference date and week start
cast/4 and validate/4 take :today and :first_day_of_week opts
rather than reading the clock or a locale, so relative dates and
week-sensitive presets are testable at a fixed point. They default to
Date.utc_today/0 and 1 (Monday, the ISO 8601 default) — which is what
Flicker.Query.parse/2 passes when a caller says nothing.
Summary
Functions
Casts raw for facet under the requested operator.
The Flicker.Messages key that renders reason.
The range-typed facet types — the ones whose cast value is a
Flicker.Facet.Range.
Every reason a value can be rejected with.
cast/4 with the value discarded — :ok, or the reason it failed.
Types
Functions
@spec cast(Flicker.Facet.t(), String.t(), Flicker.Facet.operator(), keyword()) :: {:ok, Flicker.Facet.operator(), term()} | {:error, reason()}
Casts raw for facet under the requested operator.
Returns {:ok, resolved_operator, value}, or {:error, {reason, params}}
— never raises, for any input.
Examples
iex> facet = %Flicker.Facet{key: :price, type: :integer}
...> Flicker.Facet.Cast.cast(facet, "42", :eq)
{:ok, :eq, 42}
iex> facet = %Flicker.Facet{key: :price, type: :number_range}
...> Flicker.Facet.Cast.cast(facet, "10..50", :between)
{:ok, :between, %Flicker.Facet.Range{from: 10, to: 50}}
iex> facet = %Flicker.Facet{key: :price, type: :integer}
...> Flicker.Facet.Cast.cast(facet, "abc", :eq)
{:error, {:bad_integer, %{value: "abc"}}}
The Flicker.Messages key that renders reason.
Unknown reasons — a host's own :validate returning something bespoke —
fall back to :invalid_value, so a custom reason renders a generic message
instead of crashing the render.
Examples
iex> Flicker.Facet.Cast.message_key(:bad_integer)
:invalid_integer
iex> Flicker.Facet.Cast.message_key(:not_in_values)
:invalid_not_in_values
iex> Flicker.Facet.Cast.message_key(:something_a_host_invented)
:invalid_value
@spec range_types() :: [Flicker.Facet.type()]
The range-typed facet types — the ones whose cast value is a
Flicker.Facet.Range.
Examples
iex> Flicker.Facet.Cast.range_types()
[:date_range, :datetime_range, :number_range]
@spec reasons() :: [atom()]
Every reason a value can be rejected with.
The single source of truth for the failure taxonomy, so a test can sweep it
and prove Flicker.Messages answers for each — the same way
Flicker.ThemeTest sweeps theme parts. A new reason that nobody wrote a
message for fails that test rather than rendering an empty error.
Examples
iex> :not_in_values in Flicker.Facet.Cast.reasons()
true
@spec validate(Flicker.Facet.t(), String.t(), Flicker.Facet.operator(), keyword()) :: :ok | {:error, reason()}
cast/4 with the value discarded — :ok, or the reason it failed.
Examples
iex> facet = %Flicker.Facet{key: :price, type: :integer}
...> Flicker.Facet.Cast.validate(facet, "42", :eq)
:ok
iex> facet = %Flicker.Facet{key: :price, type: :integer, bounds: %{min: 0, max: 100}}
...> Flicker.Facet.Cast.validate(facet, "900", :eq)
{:error, {:out_of_bounds, %{min: 0, max: 100, value: 900}}}