crabster

Modules de génération

Ce document n'existe qu'en un seul exemplaire dans le dépôt : les deux langues du site pointent sur le même texte.

{% raw %} Templates du code généré — un sous-répertoire par module de génération (core, record, auth-jwt, docker, ci-github-actions, …), chacun accompagné de son descripteur de métadonnées.

Trois modules existent : core/ (serveur, configuration, santé, métriques, document OpenAPI, limites HTTP), record/ (persistance, DTO, endpoints CRUD, migrations) et auth-jwt/ (comptes, sessions, rôles).

Ce qui allume un module🔗

core est toujours généré. record l'est dès qu'il y a un modèle de domaine — demandé par le fait d'en avoir un, pas par un réglage : un projet avec des enregistrements et sans persistance n'est pas quelque chose qu'on veut dire. Tout le reste est allumé par un réglage du bloc service, que le manifeste du module réclame :

[activation]
setting = "auth"
value   = "jwt"

Il n'y a pas de table de correspondance côté Rust, et c'est le point : une table serait le seul endroit qu'un nouveau module ne pourrait pas atteindre en étant déposé ici. Le parseur CDL ne refuse donc plus un réglage inconnu — il ne peut pas savoir ce qui est installé — et c'est le moteur qui le refuse, avec la ligne que le parseur a conservée et la liste des réglages auxquels les modules installés répondent vraiment. --with et --without passent outre.

La liste résolue est écrite dans .crabster/project.toml et relue par crabster record : régénérer un projet auth-jwt sans auth-jwt signalerait chacun de ses fichiers comme un fichier que personne n'a demandé, et --force les supprimerait.

Les noms qu'un module occupe🔗

Un enregistrement ou une énumération dont le nom généré est déjà porté par un template produit un projet qui s'analyse et ne compile pas — ce que le langage promet impossible. Un module déclare donc les siens :

[[reserves]]
name   = "AuthUser"
reason = "le module `auth-jwt` le déclare pour la table des comptes"

Vérifié seulement quand le module est effectivement généré : AuthUser est un nom qu'un projet sans comptes est libre de prendre, et refuser un modèle pour un module qu'il ne contient pas serait un refus sur lequel personne ne peut agir.

Les noms qu'occupent core et record restent, eux, dans crabster-cdl : ils y sont tenus par un test qui rend un modèle-sonde, lit le résultat avec syn et exige que tout nom importé par un module généré soit un nom qu'un modèle ne peut pas prendre. C'est un lien plus fort qu'un manifeste, parce qu'il lit les templates plutôt que de répéter ce qu'ils contiennent.

Il n'y aura pas de modules db-postgres, db-mysql ni db-sqlite, contrairement à ce que la Phase 5 annonçait : ils seraient vides. La chaîne de connexion est une ligne de .env.example, le pool est celui de SeaORM, le pilote est une feature de Cargo.toml, et les types de colonnes viennent de la vue et non d'un template. La base choisie est une variable, pas un module — et les rares différences qu'elle produit sont dans view.rs, à un seul endroit.

Anatomie d'un module🔗

core/
├── module.toml          ← manifeste : nom, dépendances, variables attendues
├── Cargo.toml.tera      ← rendu vers Cargo.toml
├── .gitignore.tera      ← rendu vers .gitignore
└── src/main.rs.tera     ← rendu vers src/main.rs

La destination se déduit du chemin, en retirant .tera — elle n'est déclarée nulle part ailleurs, donc elle ne peut pas diverger. Ajouter un fichier au module, c'est le déposer ici : rien à enregistrer côté Rust.

Un template qui ne rend que du blanc n'écrit aucun fichier. C'est ainsi qu'un module dit « pas cette fois » : src/domain/enums.rs.tera est enveloppé dans un {% if model.enums | length > 0 %}, et un modèle sans énumération n'obtient pas un fichier que personne ne déclare ni ne lit. core/docker-compose.yml.tera fait de même sur la base choisie — SQLite est un fichier, il n'y a pas de serveur à démarrer, donc pas de fichier Compose à expliquer. Aucun enregistrement côté Rust n'est nécessaire pour cela non plus.

Le module.toml déclare les variables que les templates utilisent. Elles sont vérifiées avant tout rendu, ce qui donne un message nommant le module et la variable au lieu d'une erreur Tera au fond d'un template.

Écrire dans un fichier qu'un autre module possède🔗

Un fichier a un seul propriétaire — c'est ce qui garde la destination déductible du chemin. Un module qui a quelque chose à y ajouter passe par un slot : le propriétaire ouvre un point d'insertion nommé, les autres y écrivent.

Côté propriétaire, une fonction Tera :

[dependencies]
axum = "0.8"
{{ slot(name="dependencies") }}

Côté contributeur, un répertoire symétrique de _each_record/ :

record/_into/Cargo.toml/dependencies.tera    ← s'ajoute à [dependencies]
record/_into/src/health.rs/probes.tera       ← s'ajoute au readiness

_into/<destination>/<slot>.tera : le répertoire est la destination, le nom du fichier est le slot. Aucune clé de manifeste, donc rien qui puisse diverger du chemin — la même règle que partout ailleurs ici.

L'exemple complet est src/health.rs. Il appartient à core, qui ne connaît aucune base de données ; record y contribue un import, un champ sur Dependencies, la sonde qui interroge la base, et ses tests. Un projet sans modèle de domaine garde l'endpoint et n'a rien à lister : un slot sans contributeur rend "", et le propriétaire décide quoi écrire à la place.

Trois règles :

  1. L'ordre des contributions est l'ordre de résolution des modules. requires ordonne ; il n'y a pas de clé priority.
  2. Une contribution orpheline fait échouer la génération, en nommant les slots qui existent — qu'elle vise un slot que personne n'ouvre, ou un fichier qu'aucun module actif ne génère. C'est le pendant de deny_unknown_fields : une faute de frappe dans un nom de slot ne peut pas être silencieuse.
  3. Une contribution est un bloc de texte, pas un fichier. Les blancs de fin sont retirés, donc deux contributions ne se collent ni ne s'écartent selon le nombre de retours à la ligne que le fichier source se trouve avoir.

Créer un module ou un blueprint🔗

Un blueprint est un répertoire contenant des modules, passé à la génération :

crabster new mon-app --blueprint ./mon-blueprint

La surcharge se fait template par template, pas module par module. Un blueprint qui fournit core/README.md.tera remplace ce seul fichier et hérite des huit autres ; un fichier de nom inédit s'ajoute au module. Vous n'avez donc à maintenir que ce que vous modifiez réellement.

Aucune modification du moteur n'est nécessaire — c'est précisément le critère de sortie de la Phase 2.

Un chemin de blueprint inexistant, ou un répertoire ne contenant aucun module, fait échouer la commande plutôt que de retomber silencieusement sur les modules du cœur.

Deux garde-fous s'appliquent aux blueprints, qui sont du code tiers : les liens symboliques sont ignorés (un blueprint ne peut pas aspirer des fichiers de la machine), et une destination sortant du projet est refusée. Un répertoire de module qui est lui-même un lien n'est donc pas un module — et si le blueprint n'en contient aucun autre, la commande échoue plutôt que de retomber silencieusement sur les modules du cœur.

Un fichier qui n'est pas du texte — un .DS_Store, par exemple — n'est pas un template et est ignoré, sans faire échouer le module.

Deux règles non négociables🔗

Le code écrit ici est copié dans les projets des utilisateurs. Il est donc tenu à un standard plus strict que le code du générateur :

  1. Lisibilité d'abord. Pas d'astuce, pas d'abstraction gratuite. Une personne qui découvre Rust doit pouvoir lire le fichier généré et le comprendre.

  2. Séparer le généré de l'éditable. Un template appelle {{ region(name="…") }} pour ouvrir une zone qui appartient au propriétaire du projet : ce qui s'y trouve survit à la régénération octet pour octet, et n'est même pas passé à rustfmt. Tout le reste est régénéré, et une modification faite ailleurs fait s'arrêter crabster record plutôt que d'être écrasée.

    Une zone se place entre des items ou des instructions, jamais à l'intérieur d'une expression : c'est là que rustfmt est le moins fiable, et le moteur refuse la génération si le formatage a déplacé ou avalé un marqueur. Une zone commence toujours vide — pas de contenu par défaut, donc pas de dérive entre deux versions d'un template.

    C'est le premier des deux mécanismes de l'ADR-0002 ; la fusion 3-voies arrive en Phase 11.

Licence🔗

Le contenu de ce répertoire est sous la licence du dépôt, mais le code qu'il produit appartient à l'utilisateur du générateur — voir LICENSE.md. {% endraw %}