Svelixir.Config (svelixir v0.11.0)

Copy Markdown

The whole scaffolder config for one project, as a struct — one child struct per section of the templates in priv/templates.

Both project types are this same struct. project.type is a field, not a separate shape, because the two templates differ in exactly four values and nothing else: project.type, assets.type, and the two assets.ui flags. Modelling them as two structs would duplicate twenty-five identical fields to express four.

The two are reached through their presets, which correspond 1:1 to the template files:

Svelixir.Config.Otp.new!()   # priv/templates/base_otp_app.exs
Svelixir.Config.Web.new!()   # priv/templates/base_web_app.exs

new!/1 here is the unpresetted form, taking every section verbatim. Prefer a preset unless you genuinely mean "no project type chosen for me".

Canonical names

The names below are canonical. The scaffolder skills reached the same switches under older spellings, and the templates used to carry three "reconcile before anything reads this" markers; this is the reconciliation, and the older spellings are retired rather than aliased. Nothing accepts them as input — a config.toml reader is where the translation belongs, and this is the table it should translate to.

CanonicalSkills spellingNotes
project.github_orggithub.orgSectioned in TOML, one flat atom here
auth.localoptions.local_auth
auth.oidcoptions.oidc_authForces auth.local
auth.samloptions.saml_authForces auth.local
container.composeoptions.podman, options.dockerTwo booleans collapse to one tri-state

container.compose is the only one that changes shape rather than just spelling. Two independent booleans admit docker: true, podman: false, which is not a thing a project can be; [:none, :podman, :docker] is ordered, so :docker outranks :podman and the impossible state is unrepresentable.

Two further spellings appear in the templates' old comments — config.toml's git_org and composer. Neither occurs anywhere in this repository or in the scaffolder skills, so they are recorded here as unverified history rather than carried forward as fact.

Why this exists

The templates document five rules and enforce none of them, saying outright that keeping them in step "is on you". Three are implications and two are hard constraints, and nothing that reads a keyword list can tell the difference. Here they are code.

Construction runs in the order the templates specify — implications expanded before validation:

  1. otp.repo forces otp.supervisor (a repo is a supervised process)
  2. auth.oidc or auth.saml forces auth.local (both issue app tokens through that path)
  3. cache.backend: :valkey forces container.sidecars.valkey, and security.openbao forces container.sidecars.openbao
  4. any sidecar forces container.compose to at least :podman

Then, and only then, the two hard constraints raise Svelixir.Config.InvalidError: any auth method requires project.type: :web, and assets.type of :api or :both requires it too.

Step 3 must precede step 4, or a :valkey backend would leave compose at :none — a cache with nothing to run it in.

Consequence

A constructed %Svelixir.Config{} is always self-consistent, so no caller ever re-derives an implication. config.otp.supervisor is simply correct.

iex> config = Svelixir.Config.new!(otp: [repo: true, supervisor: false])
iex> config.otp.supervisor
true

Undeclared keys raise rather than being ignored, which is what makes a mistyped flag a failure instead of a silent default.

Reading svelixir.exs

read!/1 is the other way in. It reads a project's hand-authored svelixir.exs — the four-key slim shape of SDD 8.1 — validates the file, then resolves it through the preset its base: names. What comes back is the same fully-expanded struct every other caller sees, not a transcription of the file: base: is project.type, and the file supplies four of project's six values. There is deliberately no second struct for the literal file, because there would be nothing for it to express.

Validation happens at two layers, and they are not competitors. This layer validates a file — keys, types, formats — before any struct exists to hang a Vex declaration off. The sections validate the resolved config. Both raise Svelixir.Config.InvalidError, so a caller sees one exception type.

Three asymmetries between the file and this struct are deliberate:

  • schema_version sits at the top level of the file and at project.schema_version here, which is where both templates already put it.
  • components: must be []. It is validated and then dropped: [] is the only legal value in schema_version 1, so the key carries no information, and a root field for it would be a field neither template has. Accepting a non-empty list would silently promise something nothing delivers, so the scope boundary is a loud runtime error.
  • The file's project: must be a keyword list, while new!/1 accepts a map as readily. The file is hand-authored and 8.1's only specimen is a keyword list, with ordering semantics a map does not share; the in-memory constructor has no literal to be faithful to.

Every key of the file is required and unknown keys are rejected rather than ignored: a silently-ignored key reads to the author as "applied".

The implication rules above cannot vary with the file. Nothing in svelixir.exs reaches otp.repo, auth.*, cache.backend or security.openbao, so with components: [] every field they read is fixed at its preset value and they resolve identically for every file this reader accepts. They stay because new!/1 is a public entry point too.

Summary

Functions

Resolves already-read svelixir.exs source. file names it in errors only.

Builds this section, and any nested block, from input.

The absolute path to target's svelixir.exs.

Reads and resolves target's svelixir.exs.

Types

Functions

decode!(source, file)

@spec decode!(binary(), Path.t()) :: t()

Resolves already-read svelixir.exs source. file names it in errors only.

new!(input \\ [])

@spec new!(keyword() | map()) :: t()

Builds this section, and any nested block, from input.

path(target)

@spec path(Svelixir.Target.t()) :: Path.t()

The absolute path to target's svelixir.exs.

There is no filename/0 beside it: a public accessor over a module attribute has no caller that path/1 does not serve better.

read!(target)

@spec read!(Svelixir.Target.t()) :: t()

Reads and resolves target's svelixir.exs.

Raises File.Error if the file is absent, Svelixir.Exs.DecodeError if it is not plain data, and Svelixir.Config.InvalidError if it is plain data of the wrong shape. The target's mix.exs is never consulted, so this works against a project that does not compile.

valid?(self)