Flicker.Facet.Cast (Flicker v0.1.0)

Copy Markdown View Source

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 a multiple?: true facet resolves to :in or stays :eq depending 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 a multiple?: true facet.

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

Types

Why a value was rejected, and the details a message needs.

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

reason()

@type reason() :: {atom(), map()}

Why a value was rejected, and the details a message needs.

Functions

cast(facet, raw, operator, opts \\ [])

@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"}}}

message_key(arg1)

@spec message_key(atom()) :: atom()

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

range_types()

@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]

reasons()

@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

validate(facet, raw, operator, opts \\ [])

@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}}}