README.md

# Changelog

All notable changes to Gladius are documented here.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
Gladius adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## [0.4.0] — Unreleased

### Added

#### Schema extension — `extend/2` and `extend/3`

`extend/2` builds a new `%Schema{}` from an existing one by merging in additional
or overriding keys. No structs were added — the output is a plain `%Schema{}`.

```elixir
base = schema(%{
  required(:name)  => string(:filled?),
  required(:email) => string(:filled?, format: ~r/@/),
  required(:age)   => integer(gte?: 0)
})

create = extend(base, %{required(:password) => string(min_length: 8)})
update = extend(base, %{optional(:role) => atom(in?: [:admin, :user])})
patch  = selection(update, [:name, :email, :age, :role])
```

Semantics:
- Extension keys that override a base key replace the spec and `required?` flag
  **in-place** — the key stays at its original position in the schema
- New extension keys are appended after all base keys
- `open?` is inherited from the base schema; override with `extend/3` `open:` opt
- Does not mutate the base — always returns a new `%Schema{}`
- `extend/2` can be chained — the result of `extend` can be extended again
- All coercions, transforms, defaults, and custom messages on the original spec
  are replaced when a key is overridden (the extension key's spec is used as-is)

#### Ecto nested embed support

`Gladius.Ecto.changeset/2-3` now builds proper nested `%Ecto.Changeset{}` values
for fields whose spec is (or wraps) a `%Gladius.Schema{}` or `list_of(schema(...))`,
and registers those fields with Ecto embedded types in `cs.types`. This makes
nested changeset fields compatible with Phoenix `inputs_for/4` in LiveView.

**Type declarations injected after cast:**

- `%Schema{}` field → `{:parameterized, Ecto.Embedded, %Ecto.Embedded{cardinality: :one, field: name}}`
- `list_of(schema)` field → `{:parameterized, Ecto.Embedded, %Ecto.Embedded{cardinality: :many, field: name}}`

Embed types are injected **after** `Ecto.Changeset.cast/4` (which uses `:map` for
safety) to avoid `Ecto.Type.cast_fun/1` raising on unknown parameterized types.

**List of embedded schemas:**

```elixir
schema(%{
  required(:name) => string(:filled?),
  required(:tags) => list_of(schema(%{required(:name) => string(:filled?)}))
})

cs = Gladius.Ecto.changeset(s, params)
cs.changes.tags  #=> [%Ecto.Changeset{}, ...]
cs.types[:tags]  #=> {:parameterized, Ecto.Embedded, %Ecto.Embedded{cardinality: :many}}
```

#### `Gladius.Ecto.traverse_errors/2`

New public function that recursively collects errors from Gladius-built nested
changesets. Use this instead of `Ecto.Changeset.traverse_errors/2` — Ecto's
built-in only recurses into fields whose type is a declared embed or association,
and does not find errors in Gladius nested changesets.

```elixir
Gladius.Ecto.traverse_errors(cs, fn {msg, _} -> msg end)
#=> %{name: ["can't be blank"], address: %{zip: ["must be exactly 5 characters"]}}
#   tags: [%{}, %{name: ["must be filled"]}]   # list form for many embeds
```

### Fixed

- `Gladius.Ecto` — string-keyed params inside nested maps and lists now atomize
  recursively. Previously `%{"address" => %{"zip" => "bad"}}` and
  `[%{"name" => "x"}]` kept string keys in nested positions, causing Gladius
  schemas (which use atom keys) to report all nested required fields as missing.

### Changed

- `Gladius.Ecto.changeset/2-3` — nested schema fields now produce `%Ecto.Changeset{}`
  values in `changes` instead of plain maps, and embed type entries in `types`.
  Existing code that accessed `cs.changes.nested_field` as a plain map will need
  to access it as a changeset: `cs.changes.nested_field.changes`.

---

## [0.3.0] — Unreleased

### Added

#### Custom error messages — `message:` option

Every spec builder and combinator now accepts a `message:` option that overrides
the generated error string for any failure. Accepts two forms:

- **String** — returned as-is, bypasses any configured translator.
- **Tuple `{domain, msgid, bindings}`** — dispatched through a translator if
  configured; falls back to `msgid` when no translator is set.

```elixir
string(:filled?, message: "can't be blank")
integer(gte?: 18, message: {"errors", "must be at least %{min}", [min: 18]})
coerce(integer(), from: :string, message: "must be a valid number")
transform(string(), &String.trim/1, message: "normalization failed")
maybe(string(:filled?), message: "must be a non-empty string or nil")
```

Supported on: all primitive builders (`string`, `integer`, `float`, `number`,
`boolean`, `atom`, `map`, `list`, `any`), `coerce/2`, `transform/2`, `maybe/1`,
`default/2`, `all_of/1`, `any_of/1`, `not_spec/1`, `schema/1`, `open_schema/1`,
and the `spec/1` macro.

#### i18n translator hook — `Gladius.Translator`

New `Gladius.Translator` behaviour for plugging in a custom message translator.
Configure via application env:

```elixir
config :gladius, translator: MyApp.GladiusTranslator
```

When configured, all built-in error messages pass through
`translator.translate(domain, msgid, bindings)`. Plain string `message:` overrides
bypass the translator. Designed to be compatible with Gettext, LLM-based
translation, or any custom backend.

#### Structured error metadata — `message_key` and `message_bindings`

`%Gladius.Error{}` gains two new fields populated by every built-in error:

- `message_key :: atom() | nil` — the predicate that failed (`:gte?`, `:filled?`,
  `:type?`, `:coerce`, `:transform`, `:validate`, etc.)
- `message_bindings :: keyword()` — dynamic values used in the message
  (e.g. `[min: 18]` for a `gte?` failure, `[format: ~r/@/]` for a format failure)

These fields are always populated regardless of whether `message:` is set,
allowing translators and custom renderers to work from structured data.

#### Partial schemas — `selection/2`

`selection/2` takes an existing `%Gladius.Schema{}` and a list of field names,
returning a new schema with only those fields — all made optional. The primary
use case is PATCH endpoints.

```elixir
patch = selection(user_schema, [:name, :email, :age, :role])

Gladius.conform(patch, %{})              #=> {:ok, %{}}
Gladius.conform(patch, %{name: "Mark"}) #=> {:ok, %{name: "Mark"}}
Gladius.conform(patch, %{age: -1})      #=> {:error, [...]}
```

- Selected fields absent → omitted from output, no error
- Selected fields present → validated by their original spec; all coercions,
  transforms, defaults, and custom messages apply
- Non-selected fields in input → rejected by closed schemas (prevents mass-assignment)
- `open?` is inherited from the source schema

#### Cross-field validation — `validate/2`

`validate/2` attaches validation rules that run only after the inner spec fully
passes. Multiple calls chain by appending rules to the same `%Gladius.Validate{}`
struct — they do not nest.

```elixir
schema(%{
  required(:start_date) => string(:filled?),
  required(:end_date)   => string(:filled?)
})
|> validate(fn %{start_date: s, end_date: e} ->
  if e >= s, do: :ok, else: {:error, :end_date, "must be on or after start date"}
end)
|> validate(&check_business_hours/1)
```

Rule return values: `:ok`, `{:error, field, message}`, `{:error, :base, message}`,
`{:error, [{field, message}]}`. Exceptions are caught and returned as
`%Error{predicate: :validate}`. All rules run; errors accumulate.

#### Ecto nested changeset support + `Gladius.Ecto.traverse_errors/2`

`Gladius.Ecto.changeset/2-3` now builds proper nested `%Ecto.Changeset{}`
structs for fields whose spec is (or wraps) a `%Gladius.Schema{}`. Nested
errors appear in the nested changeset rather than the parent's `errors` list,
making them compatible with Phoenix `inputs_for` and deep error inspection.

New `Gladius.Ecto.traverse_errors/2` recursively collects errors from nested
changesets. Use it instead of `Ecto.Changeset.traverse_errors/2` — Ecto's
built-in only recurses into declared embed/assoc typed fields.

```elixir
cs = Gladius.Ecto.changeset(user_schema, params)
Gladius.Ecto.traverse_errors(cs, fn {msg, _} -> msg end)
#=> %{name: ["can't be blank"], address: %{zip: ["must be exactly 5 characters"]}}
```

Also fixed: string-keyed nested params are now deep-atomized before conforming,
so Phoenix-style `%{"address" => %{"zip" => "bad"}}` params work correctly.

### Changed

- `%Gladius.Error{}` — two new fields: `message_key :: atom() | nil` and
  `message_bindings :: keyword()`. Existing fields unchanged; new fields default
  to `nil` and `[]` respectively, so existing pattern matches continue to work.
- `%Gladius.Spec{}`, `%Gladius.All{}`, `%Gladius.Any{}`, `%Gladius.Not{}`,
  `%Gladius.Maybe{}`, `%Gladius.Schema{}`, `%Gladius.Default{}`,
  `%Gladius.Transform{}` — new `message` field (defaults to `nil`). Existing
  struct literals without `message:` continue to work.
- `conformable()` type union extended with `Gladius.Validate`.
- `Gladius.Gen.gen/1` and `Gladius.Typespec.to_typespec/1` handle `%Validate{}`
  by delegating to the inner spec.
- `Gladius.Ecto.changeset/2-3` — nested schema fields produce nested changesets
  rather than plain map changes. `cs.changes.nested_field` is now an
  `%Ecto.Changeset{}` rather than a raw map for schema-typed fields.

---

## [0.2.0] — Unreleased

### Added

#### Default values — `default/2`

New combinator that injects a fallback when an optional schema key is absent.
The fallback is injected as-is — the inner spec only runs when the key is present.

```elixir
schema(%{
  required(:name)    => string(:filled?),
  optional(:role)    => default(atom(in?: [:admin, :user]), :user),
  optional(:retries) => default(integer(gte?: 0), 3)
})
```

- Absent key → fallback injected; inner spec not run
- Present key → inner spec validates the provided value normally
- Invalid provided value → error returned; fallback does not rescue it
- Required key → `default/2` has no effect on absence
- Composes with `ref/1` — a ref pointing to a `%Default{}` resolves correctly

#### Post-validation transforms — `transform/2`

New combinator that applies a function to the shaped value after validation
succeeds. Never runs on invalid data. Exceptions from the transform function
are caught and surfaced as `%Gladius.Error{predicate: :transform}`.

```elixir
schema(%{
  required(:name)  => transform(string(:filled?), &String.trim/1),
  required(:email) => transform(string(:filled?, format: ~r/@/), &String.downcase/1)
})

# Chainable via pipe — transform/2 is spec-first:
string(:filled?)
|> transform(&String.trim/1)
|> transform(&String.downcase/1)
```

- Runs after coercion and validation: `raw → coerce → validate → transform → {:ok, result}`
- Absent optional keys with `default(transform(...), val)` bypass the transform
- `gen/1` and `to_typespec/1` delegate to the inner spec

#### Struct validation

`conform/2` now accepts any Elixir struct as input. The struct is converted
to a plain map via `Map.from_struct/1` before dispatch. Output is a plain map.

```elixir
Gladius.conform(schema, %User{name: "Mark", email: "mark@x.com"})
#=> {:ok, %{name: "Mark", email: "mark@x.com"}}
```

`conform_struct/2` validates a struct and re-wraps the shaped output in the
original struct type on success.

```elixir
Gladius.conform_struct(schema, %User{name: "  Mark  ", age: "33"})
#=> {:ok, %User{name: "Mark", age: 33}}
```

`defschema` now accepts a `struct: true` option that defines both the
validator functions and a matching output struct in a single declaration.
The struct module is named `<CallerModule>.<PascalName>Schema`.

```elixir
defmodule MyApp.Schemas do
  import Gladius

  defschema :point, struct: true do
    schema(%{required(:x) => integer(), required(:y) => integer()})
  end
end

MyApp.Schemas.point(%{x: 3, y: 4})
#=> {:ok, %MyApp.Schemas.PointSchema{x: 3, y: 4}}
```

#### Ecto integration — `Gladius.Ecto`

New optional module `Gladius.Ecto` (guarded by
`Code.ensure_loaded?(Ecto.Changeset)`) that converts a Gladius schema into an
`Ecto.Changeset`. Requires `{:ecto, "~> 3.0"}` in the consuming application's
dependencies — Gladius does not pull it in transitively.

```elixir
# Schemaless (create workflows)
Gladius.Ecto.changeset(gladius_schema, params)

# Schema-aware (update workflows)
Gladius.Ecto.changeset(gladius_schema, params, %User{})
```

- String-keyed params (the Phoenix default) are normalised to atom keys
  before conforming — no manual atomisation step needed
- On `{:ok, shaped}` — changeset is valid; `changes` contains the fully
  shaped output with coercions, transforms, and defaults applied
- On `{:error, errors}` — changeset is invalid; each `%Gladius.Error{}` is
  mapped to `add_error/3` keyed on the last path segment
  (`%Error{path: [:address, :zip]}` → `add_error(cs, :zip, ...)`)
- Returns a plain `%Ecto.Changeset{}` — pipe Ecto validators after as normal

### Changed

- `conformable()` type union extended with `Gladius.Default` and
  `Gladius.Transform`
- `Gladius.Gen.gen/1` and `Gladius.Typespec.to_typespec/1` now handle
  `%Default{}` and `%Transform{}` by delegating to their inner spec

---

## [0.1.0] — unreleased

First public release.

### Spec algebra

- **Primitive builders** — `string/0-2`, `integer/0-2`, `float/0-2`,
  `number/0`, `boolean/0`, `atom/0-1`, `map/0`, `list/0-2`, `any/0`,
  `nil_spec/0`
- **Named constraints** — `filled?`, `gt?`, `gte?`, `lt?`, `lte?`,
  `min_length:`, `max_length:`, `size?:`, `format:`, `in?` — introspectable
  and generator-aware
- **Arbitrary predicates** — `spec/1` for cases named constraints can't cover
- **Combinators** — `all_of/1` (intersection), `any_of/1` (union),
  `not_spec/1` (complement), `maybe/1` (nullable), `list_of/1` (typed list),
  `cond_spec/2-3` (conditional branching)
- **Coercion** — `coerce/2` wraps any spec with a pre-processing step;
  runs before type-checking and constraints
- **Schemas** — `schema/1` (closed) and `open_schema/1`; errors accumulated
  across all keys in one pass, no short-circuiting

### Registry

- `defspec/2-3` — registers a named spec globally in ETS; accessible from
  any process via `ref/1`
- `defschema/2-3` — generates `name/1` and `name!/1` validator functions in
  the calling module
- `ref/1` — lazy registry reference; resolved at conform-time, enabling
  circular schemas
- Process-local overlay (`register_local/2`) for async-safe test isolation

### Coercion pipeline

- **Built-in source types** — `:string`, `:integer`, `:atom`, `:float`
- **Built-in pairs** — 11 source→target coercions: string→integer/float/
  boolean/atom/number, integer→float/string/boolean, atom→string,
  float→integer/string
- **User-extensible registry** — `Gladius.Coercions.register/2` backed by
  `:persistent_term`; user coercions take precedence over built-ins

### Generator inference

- `gen/1` — infers a `StreamData` generator from any spec
- Supports all primitives, combinators, and schemas
- Bounds-over-filters strategy for constrained numeric/string specs
  (avoids `FilterTooNarrowError`)
- Custom generators via `spec(pred, gen: my_generator)`

### Function signature checking

- `use Gladius.Signature` — opt-in per module
- `signature args: [...], ret: ..., fn: ...` — declares arg specs, return
  spec, and optional relationship constraint
- Validates and coerces all args before the impl runs; coerced values are
  forwarded (not the originals)
- Multi-clause functions: declare `signature` once before the first clause
- **Path errors** — all failing args reported in one raise; each error path
  prefixed with `{:arg, N}` so nested schema field failures render as
  `argument[0][:email]: must be filled`
- Zero overhead in `:prod` — signatures compile away entirely

### Typespec bridge

- `to_typespec/1` — converts any Gladius spec to quoted Elixir typespec AST
- `typespec_lossiness/1` — reports constraints that have no typespec
  equivalent (string format, negation, intersection, etc.)
- `type_ast/2` — generates `@type name :: type` declaration AST for macro
  injection
- `defspec :name, spec, type: true` — auto-generates `@type` with
  compile-time lossiness warnings
- `defschema :name, type: true do ... end` — same for schemas
- Integer constraint specialisation: `gte?: 0` → `non_neg_integer()`,
  `gt?: 0` → `pos_integer()`, `gte?: a, lte?: b` → `a..b`

---

[0.4.0]: https://github.com/Xs-and-10s/gladius/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/Xs-and-10s/gladius/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/Xs-and-10s/gladius/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/Xs-and-10s/gladius/releases/tag/v0.1.0