Plan de travail
roadmap
{% raw %} Ce document découpe la construction de Crabster en phases séquentielles (avec quelques parallélisations possibles indiquées). Chaque phase précise : objectif, livrables, tâches, critères de sortie (Definition of Done) et dépendances.
Convention : les phases 0 à 10 constituent le périmètre V1 (API-only), détaillé ici. Les phases 11 et suivantes relèvent de la V2 et sont détaillées dans le périmètre V2. La phase C (documentation) est continue et traverse les deux versions.
Vue d'ensemble🔗
| Phase | Titre | Scope | Dépend de |
|---|---|---|---|
| 0 | Fondations du projet | V1 | — |
| 1 | CLI squelette + projet minimal | V1 | 0 |
| 2 | Moteur de templates & orchestration | V1 | 1 |
| 3 | Langage CDL + génération CRUD | V1 | 2 |
| 4 | Authentification & sécurité | V1 | 3 |
| 5 | Multi-base de données | V1 | 3 |
| 6 | Documentation API, validation, erreurs | V1 | 3 |
| 7 | Tests & qualité générés | V1 | 3, 4 |
| 8 | Docker, déploiement, CI/CD | V1 | 1, 3 |
| 9 | Observabilité | V1 | 1 |
| 10 | Blueprints & extensibilité | V1 (mécanisme) | 2 |
| C | Documentation utilisateur & communauté | Continu | 1 (démarre dès qu'il y a quelque chose à documenter) |
| 11-18 | (voir périmètre V2) | V2 | V1 complète |
Les phases 4, 5, 6 peuvent être menées en parallèle par des contributeurs différents une fois la Phase 3 stabilisée (elles dépendent toutes de l'IR et du moteur de génération, mais pas les unes des autres).
Phase 0 — Fondations du projet🔗
Objectif : poser les bases organisationnelles et techniques avant d'écrire du code de génération.
Tâches
- Initialiser le dépôt git, structure monorepo Cargo workspace (
crates/,examples/,docs/, les templates vivant souscrates/crabster-codegen/). - Choisir et documenter la licence (MIT/Apache-2.0 dual, alignée écosystème Rust).
- Mettre en place la CI du dépôt Crabster lui-même (build,
cargo test,cargo clippy,cargo fmt --check). - Rédiger
CONTRIBUTING.md(style de code, process de PR, DCO/CLA si nécessaire). - Choisir l'outillage de gestion de versions/publication (
cargo releaseou équivalent), politique de versionnement sémantique du générateur et des templates séparément (un breaking change de template n'est pas forcément un breaking change du CLI). - Valider ce document (vision, architecture, CDL) avec les premiers contributeurs/relecteurs.
Livrables
- Dépôt initialisé, CI verte sur un "hello world" Rust.
- Documents de vision, architecture et CDL (ce corpus) validés.
Definition of Done
cargo buildetcargo testpassent sur un dépôt vide de squelette.- Documents de fondation relus par au moins une deuxième personne.
Phase 1 — CLI squelette + projet minimal généré🔗
Objectif : crabster new produit un projet Axum minimal qui compile, répond sur un health-check, et tourne.
Tâches
- Créer le crate
crabster-cliavecclap: sous-commandenewavec options minimales (nom du projet, répertoire cible, base de données). - Définir le template
core(sans moteur Tera encore — génération "en dur" acceptable à ce stade pour aller vite, à remplacer en Phase 2). - Le projet généré contient :
Cargo.toml,main.rsavec serveur Axum minimal, un endpoint/health, configuration via fichier + variables d'environnement, loggingtracingde base. - Script/test automatisé qui : génère un projet dans un répertoire temporaire, exécute
cargo build, lance le binaire, vérifie que/healthrépond 200.
Livrables
crabster new my-appfonctionnel en local.- Test end-to-end automatisé (génération → build → run → requête HTTP) exécuté en CI.
Definition of Done
- Le test end-to-end passe en CI sur au moins Linux et macOS.
- Temps de génération + build initial documenté (mesure de référence pour l'objectif "< 5 minutes" de la vision).
Mesure de référence (2026-09-04) — macOS, Apple Silicon, registre Cargo déjà peuplé :
| Étape | Temps |
|---|---|
| Génération des 9 fichiers | ~1 ms |
cargo build initial du projet généré (dépendances à froid) | ~11 s |
| Total génération → serveur qui répond | ~12 s |
Très en deçà de l'objectif de 5 minutes, mais ce chiffre ne vaut que pour le template core : il augmentera avec l'ajout de SeaORM (Phase 3) et des dépendances de test (Phase 7), et un poste sans cache Cargo (premier cargo de la machine) paie en plus le téléchargement du registre. À re-mesurer à chaque phase qui ajoute des dépendances au projet généré.
Phase 2 — Moteur de templates & orchestration🔗
Objectif : remplacer la génération "en dur" de la Phase 1 par le moteur Tera modulaire décrit dans l'architecture, base de tout ce qui suit.
Tâches
- Créer le crate
crabster-codegen: chargement de répertoires de templates, résolution de couches (cœur → blueprint), rendu Tera, écriture de fichiers avecrustfmtautomatique en sortie. - Définir le format de métadonnées d'un "module" de génération (dépendances entre modules, fichiers produits, variables de contexte attendues).
- Migrer le template
corede la Phase 1 vers ce nouveau système. - Mettre en place un mécanisme de contexte de génération (le futur IR, en version simplifiée pour l'instant — juste les infos du projet, pas encore les enregistrements).
- Ajouter une vérification post-génération :
cargo checkautomatique sur le projet généré, échec explicite et lisible si un template produit du code invalide.
Livrables
crabster-codegencomme crate testé indépendamment (tests unitaires sur le rendu de templates).- Template
coremigré et toujours vert sur le test end-to-end de la Phase 1.
Definition of Done
- Ajouter un nouveau module de génération ne nécessite pas de modifier le code du moteur, seulement d'ajouter un répertoire de templates + métadonnées.
Résultat (2026-09-05) — DoD vérifiée. Un blueprint externe (un répertoire, un module.toml, zéro ligne de Rust) remplace le module core et génère un projet qui compile, via crabster new --blueprint.
Deux écarts assumés par rapport à l'énoncé initial :
- Les templates du cœur sont embarqués dans le binaire, pas lus sur disque : un CLI installé par
cargo installn'a pas de répertoiretemplates/à côté de lui. Les blueprints, eux, sont bien lus au runtime — l'objectif de l'ADR est donc tenu. Voir Architecture §2.3. cargo checkpost-génération est optionnel (crabster new --check) plutôt qu'automatique : il compile tout l'arbre de dépendances, transformant une commande instantanée en commande d'une minute.
Phase 3 — Langage CDL + génération CRUD🔗
Objectif : la phase la plus critique — modélisation de domaine → API CRUD complète générée. C'est le cœur de la valeur de Crabster.
Tâches
- Trancher
pestvschumskypour le parseur (cf. Architecture §2.2), prototyper les deux sur un sous-ensemble de la grammaire si le doute persiste après revue de code. - Écrire la grammaire formelle CDL v0 (reprendre l'exemple de Langage CDL, la geler après relecture communautaire).
- Implémenter le crate
crabster-cdl: parseur + validation statique (références vers enregistrements inexistants, doublons de noms, cycles interdits, contraintes incohérentes) + construction de l'IR (DomainModel,Record,Field,Enumeration). - Définir précisément l'IR en Rust (structures détaillées dans Architecture §4, à formaliser).
- Écrire les templates de génération d'enregistrement : struct SeaORM (
ActiveModel/Model), migrationsea-orm-migration, DTO, handler Axum (CRUD complet : create/get/update/delete/list), routes, pagination/tri/filtrage de base. - Implémenter
crabster import-cdl <file>: génère un projet complet à partir d'un.cdl. - Implémenter
crabster record <name>: mode interactif (ou via flags) pour ajouter un enregistrement à un projet existant sans écraser le reste — première version simple (pas de fusion avancée, juste "ajouter les fichiers manquants, refuser d'écraser un fichier modifié sans--force"). - Générer un exemple de référence (
examples/shop.cdl) testé en CI : génération, build, et tests d'intégration qui passent.
Livrables
crabster-cdlavec suite de tests couvrant la grammaire (cas valides et invalides).- Génération CRUD complète fonctionnelle sur l'exemple
shop.cdl. crabster recordfonctionnel en mode ajout simple.
Definition of Done
- L'exemple
shop.cdlgénère un projet qui compile, dont les endpoints CRUD répondent correctement (vérifié par tests d'intégration légers, avant même la Phase 7). - Les messages d'erreur du parseur CDL sont compréhensibles (ligne, colonne, explication) — aucun panic Rust brut exposé à l'utilisateur.
Résultat (2026-09-07) — phase terminée. crabster import-cdl examples/shop.cdl produit 37 fichiers pour 4 enregistrements ; le projet compile sans avertissement, passe ses propres tests, et son API répond. Un test end-to-end en CI le génère, le compile, lance ses tests puis l'exerce : création, filtre, tri, motif de validation refusant une valeur, doublon sur colonne unique en 409, référence manquante en 409, suppression d'un enregistrement encore référencé en 409.
Les références sont générées : colonne, contrainte, relations SeaORM des deux côtés, et migrations ordonnées pour créer la table référencée en premier.
Le tri est généré pour toute liste, sans attribut à écrire : ?sort=<champ>&order=asc|desc, sur une liste blanche de colonnes par enregistrement, avec un tri secondaire stable sur l'id et un 400 nommant les colonnes acceptées quand la demande n'en est pas une.
crabster record <Nom> --field "..." ajoute un enregistrement à un projet existant. Le projet conserve dans .crabster/ le modèle dont il est issu, ce qui permet de rendre l'ancien modèle à nouveau et de comparer, fichier par fichier : identique, le fichier est généré et peut bouger ; différent, il a été modifié à la main et la commande s'arrête en le nommant, sans rien écrire, à moins de --force. Les migrations déjà appliquées gardent leur numéro, et un modèle enregistré qui ne décrit plus le projet est refusé plutôt que suivi. Un projet issu de crabster new, sans modèle, gagne aussi bien son premier enregistrement.
Trois défauts qui contredisaient l'invariante de la phase — « un modèle qui s'analyse est un modèle qui peut être généré » — ont été fermés au passage : cinq noms d'enregistrement que le module de migration occupait déjà, un champ nommé active qui masquait le modèle dans le handler de mise à jour, et un attribut écrit sur sa propre ligne qui se rattachait silencieusement au champ précédent.
Phase 4 — Authentification & sécurité🔗
Objectif : JWT stateless, hachage de mots de passe, autorisation par rôle.
Tâches
- Template
auth-jwt: enregistrement utilisateur généré (ou intégré s'il est déjà présent dans le modèle), endpoints register/login/refresh. - Hachage
argon2, génération/validation JWT viajsonwebtoken, middleware Axum d'extraction/validation du token. - Modèle de rôles simple (V1) : liste de rôles sur l'utilisateur, macro/attribut pour restreindre une route à un ou plusieurs rôles.
- Génération du format d'erreur standardisé RFC 7807 pour les cas 401/403 (peut être partagé avec la Phase 6).
- Tests d'intégration : register → login → accès à une route protégée → refus sans token / avec mauvais rôle.
Livrables
- Module
auth-jwtactivable par défaut dansapplication { config { authenticationType jwt } }.
Definition of Done
- Sur le projet exemple, un utilisateur peut s'enregistrer, se connecter, obtenir un JWT, et accéder à une route protégée ; les cas d'erreur (mauvais mot de passe, token expiré, rôle insuffisant) renvoient les codes HTTP corrects.
Résultat (2026-09-07) — phase terminée. service { auth jwt } génère src/auth/ : la table des comptes et sa migration, POST /auth/register, /auth/login, /auth/refresh, GET /auth/me et GET /auth/users. Vingt-sept tests dans le projet généré, dont ceux qui tiennent la DoD — enregistrement, connexion, lecture d'une route protégée, mauvais mot de passe, token de rafraîchissement dépensé comme token d'accès, rôle absent — et un test lourd du générateur qui compile un projet avec authentification, lance ses tests et vérifie que ces cas-là ont bien tourné, parce qu'une suite qui aurait cessé de les exécuter passerait quand même.
Trois détails qui font la différence entre du code d'exemple et du code livrable. Un login qui ne trouve aucun compte hache tout de même contre un leurre, sinon une adresse inconnue répond mesurablement plus vite qu'un mauvais mot de passe — et c'est ainsi qu'on récolte une liste d'adresses valides sur un service qui n'en renvoie jamais. Le type du token est vérifié à la lecture : un token de rafraîchissement dure un mois et est signé avec la même clé, donc une route qui accepterait les deux distribuerait un accès d'un mois. Et la clé est refusée deux fois — le placeholder de .env.example hors du profil dev, et toute clé de moins de 32 octets partout — au démarrage, pour qu'un service incapable de signer n'aille jamais jusqu'à être déclaré sain.
Décision — un extracteur, pas une macro. La feuille de route annonçait une « macro/attribut pour restreindre une route ». C'est Authenticated en argument de handler et claims.require_role("ADMIN") : une méthode se lit comme le code autour d'elle, apparaît dans une trace d'appels, et n'oblige personne à savoir en quoi elle se développe. Nommer l'extracteur est ce qui protège la route, donc aucune route ne peut être annoncée protégée sans l'être. Les endpoints CRUD générés ne sont pas protégés d'office : les protéger tous changerait le sens de chaque endpoint d'un modèle, et le README généré montre la ligne à écrire pour en protéger un.
Décision — pas de limitation de débit générée. Le durcissement HTTP livré est dans core : timeout répondu en 408, limite de concurrence, limite de taille de corps, nosniff, et un CORS fermé par défaut sans joker possible. Le limiteur de débit, non : dans le processus il compte le trafic d'une seule réplique, donc le nombre configuré veut dire autre chose à chaque changement d'échelle, et ce qu'il faut limiter est en général par appelant — une identité que cette couche n'a pas. Cela appartient à ce qui termine TLS devant le service, et c'est écrit dans src/http.rs autant que ci-dessus.
Les deux points reportés de l'étape 1 sont livrés ici, contre le module qui les justifiait. L'activation : [activation] dans le manifeste, Service.settings porté par le parseur avec sa position, refus côté moteur — qui seul connaît les modules installés, blueprint compris — et --with/--without pour passer outre. La liste résolue est écrite dans .crabster/project.toml et relue, sans quoi crabster record sur un projet auth-jwt signalerait chacun de ses fichiers comme orphelin. Et les réservations : [[reserves]] dans le manifeste, vérifiées seulement quand le module est généré, parce qu'AuthUser est un nom qu'un projet sans comptes est libre de prendre.
Ce qui n'a pas bougé, et pourquoi. Les noms qu'occupent core et record restent dans crabster-cdl plutôt que de remonter dans les manifestes. Le lien qui les tient n'est pas une liste mais un test qui rend un modèle-sonde, lit le résultat avec syn et exige que tout nom importé soit un nom réservé — un lien plus fort qu'un manifeste, qui ne ferait que répéter ce que les templates contiennent. Ce que [[reserves]] apporte est ce qui manquait : un module que le parseur n'a jamais vu peut déclarer les siens.
Limite assumée. Les sessions sont sans état, comme l'objectif le demande, donc un token ne peut pas être révoqué : il reste valide jusqu'à son expiration quoi qu'il arrive au compte. D'où quinze minutes pour l'accès et trente jours pour le rafraîchissement, et les rôles relus depuis le compte à chaque rafraîchissement. Une liste de révocation est un état, et l'ajouter reviendrait sur la décision que cette phase énonce.
Phase 5 — Multi-base de données🔗
Objectif : PostgreSQL (référence), MySQL (écrit mysql ; MariaDB est compatible au niveau du protocole), SQLite, sélectionnables à la génération.
Tâches
- Abstraire les différences de dialecte SQL nécessaires dans les templates de migration (types spécifiques, auto-increment, etc. — SeaORM en gère une grande partie nativement, documenter les cas où ce n'est pas transparent).
- Templates
db-postgres,db-mysql,db-sqlite: configuration de connexion, pool, chaîne de connexion par variable d'environnement. docker-compose.ymlgénérique paramétré selon la base choisie.- Faire tourner la suite de tests d'intégration (Phase 7) sur chacune des trois bases en CI (matrice).
Livrables
- Option
prodDatabaseType/devDatabaseTypefonctionnelle pour les trois bases.
Definition of Done
- L'exemple
shop.cdlgénère et passe ses tests d'intégration sur PostgreSQL, MySQL et SQLite en CI.
Résultat (2026-09-07) — phase terminée, une réserve nommée plus bas. Les trois bases sont sélectionnables et le code généré est le même pour les trois : SeaORM porte le dialecte, sea-query rend les types, et aucun template ne teste {{ database }} pour produire du SQL. Les trois endroits où ce n'est pas transparent sont dans view.rs, et nulle part ailleurs — Timestamp devient un DATETIME sur MySQL, qui n'a pas de timestamptz ; Decimal est fixé à DECIMAL(19,4) partout ; Bytes est un BLOB. La limite qui reste est documentée plutôt que corrigée : sur SQLite un Decimal transite par un f64, parce que sqlx-sqlite ne porte pas rust_decimal. C'est écrit en §4 du langage et dans le README de tout projet SQLite qui contient un Decimal.
docker-compose.yml est généré pour PostgreSQL et MySQL, sur exactement l'hôte, le port, l'utilisateur, le mot de passe et le nom de base que .env.example désigne — les deux fichiers sont écrits à partir d'une seule valeur, et un test échoue s'ils divergent. Rien pour SQLite : il n'y a pas de serveur à démarrer, et un fichier Compose vide serait un fichier à expliquer.
Les deux jobs CI qui lancent un projet généré contre une vraie base démarrent désormais ce fichier-là, au lieu d'un service défini dans le workflow. Ce que la CI exécute est donc ce que le README généré demande d'exécuter — cp .env.example .env, docker compose up -d --wait, cargo run — et le livrable est vérifié à chaque exécution plutôt que d'être un fichier que personne ne lance.
Décision — les modules db-postgres, db-mysql et db-sqlite n'existeront pas. Il n'y aurait rien dedans : 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. Trois modules dont tout le contenu serait une substitution de variable, ce seraient trois endroits où oublier une base au lieu d'un seul. La neutralité vient de SeaORM ; la rendre explicite la rendrait fausse.
Décision — pas de prodDatabaseType/devDatabaseType : une base par projet. Avec deux, le schéma n'est pas le même des deux côtés — Timestamp est un DATETIME sur MySQL et un timestamptz ailleurs, et un Decimal ne fait pas l'aller-retour sur SQLite. Un projet développé sur SQLite et déployé sur PostgreSQL ferait donc passer ses tests contre un schéma que sa production n'a jamais : exactement la classe de défaut que l'option prétend éviter. Le besoin derrière l'option est réel — tester sans serveur à démarrer — et c'est la Phase 7 qui y répond, avec testcontainers contre la base réellement visée.
La réserve, levée depuis (Phase 7). La Definition of Done demande que shop.cdl passe ses tests d'intégration sur les trois bases. Ce n'était d'abord vrai qu'à moitié : les trois étaient exercées en CI, mais les tests du projet généré tournaient sur SQLite quelle que soit la cible, parce que ses dépendances de développement épinglaient sqlx-sqlite. La Phase 7 a retiré cet épinglage : un projet PostgreSQL fait tourner ses tests contre PostgreSQL, et les deux jobs CI qui démarrent une vraie base lancent désormais cargo test dedans. La DoD est atteinte sans réserve.
Phase 6 — Documentation API, validation, gestion d'erreurs🔗
Objectif : contrat API exploitable de l'extérieur (essentiel puisque V1 est API-only et qu'aucun frontend ne "masque" une API mal documentée).
Tâches
- Intégrer
utoipa: annotations générées sur les handlers/DTOs, endpoint/swagger-uiet/api-docs/openapi.jsongénérés par défaut. - Générer les règles
validatorsur les DTOs à partir des contraintes CDL (cf. Langage CDL §5). - Middleware d'erreur central : conversion des erreurs applicatives et de validation en réponses RFC 7807 cohérentes.
- Vérifier en CI que la spec OpenAPI générée est valide (linter type
spectralou validation de schéma).
Livrables
- Spec OpenAPI 3 générée et servie pour chaque projet, exemple
shopinclus dans le corpus de test.
Definition of Done
- Une requête invalide (champ requis manquant, longueur dépassée) renvoie une erreur 400 structurée listant les champs en cause.
- La spec OpenAPI générée passe un linter standard sans erreur.
Résultat (2026-09-07) — phase terminée. Le contrat d'erreur est de la RFC 9457 — la révision de la RFC 7807 que la feuille de route visait — servie en application/problem+json, pour toute défaillance et depuis n'importe quelle couche, y compris celles qu'Axum rejetterait lui-même en texte brut.
type est le champ que le statut ne peut pas remplacer. Un 409 pour une valeur unique déjà prise et un 409 pour une référence qui ne tient pas sont deux problèmes différents sous un même code, et la prose du detail n'est pas quelque chose sur quoi un client branche. Le type est une URI relative que le projet sert : GET /problems/unique-conflict explique ce genre-là, GET /problems les liste tous. C'est le point où about:blank aurait été plus simple et faux — il signifie « rien à dire au-delà du statut », ce qui est précisément l'inverse. La RFC demande qu'une URI de type qui est un localisateur ait de la documentation derrière ; les textes servis sont les mêmes constantes que le code d'erreur porte, donc les deux ne peuvent pas diverger, et un test généré parcourt la liste pour vérifier que chaque type mène quelque part.
Décision — la DoD est corrigée, pas le code : un corps refusé répond 422, pas 400. La RFC 9110 définit le 422 comme une requête bien formée et sémantiquement fausse, ce qui est exactement le cas ; Axum répond déjà 422 une couche plus haut pour un corps qui ne correspond pas à la forme attendue ; et fondre les deux dans un 400 rendrait « je n'ai pas pu lire ceci » indiscernable de « je l'ai lu et je n'en veux pas ». Le 400 de la DoD était un héritage Spring. Le reste de la DoD tient mot pour mot : une requête invalide renvoie une erreur structurée qui liste les champs en cause, dans errors.
Le document OpenAPI 3.1 est généré depuis les handlers eux-mêmes, servi sur /api-docs/openapi.json, avec une console sur /swagger-ui dont les assets sont compilés dans le binaire — le projet se construit sans réseau et se sert sans réseau. Chaque opération porte un operation_id explicite, parce qu'un générateur produit un list dans le module de chaque enregistrement et qu'une spécification ne peut pas nommer deux opérations pareil ; un test le vérifie sur un modèle à deux enregistrements. Un autre exige que tout handler porte son attribut, vérifié dans les deux sens.
Le linter, exécuté. Le projet généré emporte son propre .spectral.yaml, et le job CI the-api-contract lance le projet, lui demande son document et le passe au linter avec le fichier de règles du projet — vérifier avec des règles plus strictes que celles qu'on remet au lecteur reviendrait à contrôler quelque chose que personne d'autre ne peut reproduire. Seuil --fail-severity warn, plus haut que le « sans erreur » demandé : le document sort à zéro aux deux niveaux, et une barre posée là où le travail est déjà ne bouge jamais. Les 25 avertissements de départ ont été traités, pas désactivés : chaque opération a gagné un summary et une description, et le document déclare un serveur relatif. Une seule règle est coupée, info-contact, avec sa raison écrite dans le fichier : qui répond d'une API se décide là où elle tourne.
Phase 7 — Tests & qualité générés🔗
Objectif : chaque projet généré embarque une suite de tests exécutable qui donne confiance sans travail manuel.
Tâches
- Intégrer
testcontainers-rsdans les templates pour les tests d'intégration (démarrage automatique d'une instance DB éphémère). - Générer, par enregistrement, un test CRUD complet (create/read/update/delete/list, pagination, cas d'erreur basiques).
- Générer des tests pour les endpoints d'authentification (Phase 4).
- Documenter/générer une configuration de couverture de code (
cargo llvm-covou équivalent) — non bloquant en V1, mais visible.
Livrables
- Suite de tests générée exécutable via
cargo testsans configuration manuelle (Docker requis pourtestcontainers, documenté).
Definition of Done
cargo testsur un projet fraîchement généré (avec Docker disponible) passe à 100% sans intervention.
Résultat (2026-09-07) — phase terminée. Deux verrous sont tombés.
Les tests tournent contre la base que le projet vise. C'était la réserve de la Phase 5, et elle tenait à une ligne : les dépendances de développement épinglaient sqlx-sqlite, donc un projet PostgreSQL testait sur SQLite — c'est-à-dire testait un autre projet. La ligne a disparu ; les tests utilisent le pilote du projet. Pour PostgreSQL et MySQL, src/testing.rs démarre un conteneur pour l'ensemble du binaire de test et crée une base par test, parce que ces tests comptent des lignes et qu'une base partagée les ferait dépendre de l'ordre d'exécution. Pour SQLite, une base en mémoire par test : ni serveur, ni conteneur, ni Docker. Et TEST_DATABASE_URL court-circuite le conteneur, ce qu'utilisent les deux jobs CI qui démarrent déjà une vraie base — Docker dans Docker n'aurait servi à rien.
Tous les enregistrements ont des tests. Trois sur quatre de shop.cdl n'en avaient aucun : Customer était exclu par un @matches, Address et Order par une référence obligatoire. Les plus contraints étaient donc les seuls sans rien pour les vérifier.
Une référence obligatoire n'exclut plus rien : le module de chaque enregistrement expose de quoi en créer un, et l'enfant appelle celui de son parent — par l'endpoint du parent, comme le ferait un appelant. Les cycles étant refusés par le langage, la récursion ne peut pas s'emballer.
Un @matches non plus, dans la plupart des cas : la valeur d'exemple est engendrée depuis l'expression régulière elle-même, puis vérifiée contre elle. Trois choses la rendent utilisable comme donnée de test — les ancres sont retirées avant la génération parce que le générateur les refuse, l'échantillonnage se fait en octets sous unicode(false) pour qu'une valeur reste quelque chose qu'un lecteur reconnaît plutôt que de partir dans les plans astraux, et la graine est fixe pour que le même modèle produise deux fois le même test. Une table de motifs connus aurait couvert les trois que tout le monde écrit et laissé le reste du langage sans tests. Ce qui reste exclu est nommé : un motif pour lequel aucune valeur n'a été trouvée — un @matches("^[A-Z]{40}$") sous un @length(..10) — et les enregistrements qui pointent dessus, puisqu'ils ne peuvent pas créer le parent.
Ce que chaque enregistrement obtient. Trois tests : un cycle complet création / lecture / mise à jour / suppression ; une liste paginée et ordonnée, page au-delà de la fin comprise, et un ?sort= que l'enregistrement n'accepte pas ; et ce que l'endpoint refuse — 404 sur un identifiant absent en lecture, mise à jour et suppression, et 422 sur un corps vide quand le modèle exige quelque chose. Ils passent par le routeur complet, pas par les handlers : c'est la seule façon d'exercer le routage, les extracteurs, les couches et le format d'erreur.
Décision — la couverture est documentée, pas configurée. cargo llvm-cov --html sur un projet généré fonctionne sans rien installer d'autre que l'outil, et le README généré le dit. Un fichier de configuration serait un fichier de plus à maintenir pour remplacer deux mots sur une ligne de commande.
Phase 8 — Docker, déploiement, CI/CD🔗
Objectif : un projet généré est déployable "day one".
Tâches
Dockerfilemulti-stage aveccargo-chefpour le cache de dépendances.docker-compose.yml(app + DB) pour le développement local, déjà amorcé en Phase 5.- Pipeline GitHub Actions générée : build, test, clippy,
cargo audit, build/push d'image Docker (optionnel selon config). - Documenter (sans forcément générer) un template GitLab CI équivalent.
Livrables
docker buildréussi sur l'exempleshop,docker-compose upfonctionnel.- Pipeline GitHub Actions générée, testée sur un dépôt d'exemple réel (pas seulement en local).
Definition of Done
- Un projet fraîchement généré, poussé sur un dépôt GitHub neuf, voit sa CI passer sans modification manuelle du workflow généré.
Résultat (2026-09-07) — phase terminée, avec la limite de sa DoD énoncée plus bas. Un projet généré emporte trois fichiers de plus : un Dockerfile, un .dockerignore et son propre .github/workflows/ci.yml.
L'image. Quatre étages, dont deux n'existent que pour le cache de dépendances : cargo reconstruit tout dès qu'un fichier change, donc une correction d'une ligne recompilerait l'arbre entier ; cargo-chef réduit le manifeste à une recette qui ne bouge que quand les dépendances bougent. L'étage final est distroless/cc : le binaire, son config/, et rien d'autre — pas de shell, pas de gestionnaire de paquets, et l'utilisateur est nonroot. Vérifié en construisant l'image sur l'exemple et en la lançant : 61,7 Mo, /health/ready joint la base, le processus tourne en nonroot et il n'y a pas d'id à exécuter dedans pour le vérifier autrement que par docker inspect.
Le docker-compose gagne l'application, derrière un profil. docker compose up -d --wait démarre toujours la base seule, parce que pendant qu'on travaille sur le projet on veut le serveur depuis cargo run et non depuis une image à reconstruire après chaque édition. docker compose --profile app up --build monte les deux. Le service applicatif force APP_PROFILE=dev et APP__SERVER__HOST=0.0.0.0 : le profil dev se lie à la boucle locale, ce qui dans un conteneur veut dire que rien de l'extérieur ne peut l'atteindre.
Le workflow généré fait tourner le formateur, Clippy en -D warnings, les tests contre la base réellement visée, un audit de sécurité des dépendances, et une construction de l'image. Aucun secret, aucun réglage de dépôt : il passe sur un dépôt qui vient d'être créé. L'image est construite et non poussée — où une image appartient est une décision d'infrastructure, et pousser demande un registre et une identité que ce fichier ne peut pas inventer ; le workflow dit en commentaire quoi ajouter.
Une dette fermée au passage, et elle mordait ici. Le code généré n'était pas clippy-clean, et la CI de Crabster vérifiait rustfmt et jamais Clippy. Le workflow généré lançant -D warnings, chaque projet aurait vu sa propre CI échouer le jour de sa création. Trois lints existaient — items_after_test_module dans main.rs, parce que le bloc contribué par un module se terminait par ses tests et que d'autres items suivaient, et deux .err().expect() dans les tests d'authentification. Corrigés, et la CI de Crabster lance désormais Clippy sur les deux formes de projet généré.
Décision — GitLab est documenté, pas généré. La feuille de route disait « documenter sans forcément générer ». Le README généré porte le .gitlab-ci.yml équivalent, complet : on utilise une CI ou l'autre, et générer les deux laisserait un fichier mort dans chaque projet.
La limite de la DoD. « Poussé sur un dépôt GitHub neuf, voit sa CI passer » est la seule partie que rien dans ce dépôt ne peut exécuter. Ce qui est vérifié à la place : chaque commande que le workflow lance passe sur un projet généré, y compris Clippy en -D warnings ; l'image se construit et sert ; et le fichier ne contient plus aucun {{ }} non rendu — les expressions ${{ }} d'Actions et celles de Tera étant les deux mêmes accolades, un test le tient. Ce qui reste non vérifié est que GitHub accepte le fichier, et c'est dit plutôt que supposé.
Phase 9 — Observabilité🔗
Objectif : parité fonctionnelle avec Spring Actuator pour le monitoring de base.
Tâches
tracing+tracing-subscriberconfigurés par défaut (format JSON en prod, format lisible en dev).- Endpoints
/health/live,/health/ready(readiness incluant un ping DB). - Endpoint de métriques Prometheus (latence, taux d'erreur, nombre de requêtes par route au minimum).
- Documenter l'intégration OpenTelemetry en option (pas nécessairement générée par défaut).
Livrables
- Endpoints de santé et métriques présents par défaut dans tout projet généré.
Definition of Done
- Un
docker-composeincluant Prometheus (fourni en exemple, pas forcément généré par défaut) peut scraper les métriques d'un projet généré sans configuration supplémentaire.
Résultat (2026-09-07) — phase terminée. Tout projet généré répond sur quatre chemins, sans rien à configurer. /health/live ne joint rien : une politique de redémarrage le lit, et redémarrer un processus sain parce qu'une base est tombée transforme une panne en deux. /health/ready joint tout ce dont le service dépend, répond 503 dès qu'un élément manque, et nomme lequel — un endpoint de readiness qui ne renvoie qu'un code déplace la question au lieu d'y répondre. /health reste, et répond la vivacité, sous le nom qu'il avait avant. /metrics rend compteurs, latences et requêtes en vol au format Prometheus.
Le readiness est composé, et c'est le premier vrai usage des slots de l'étape 1. src/health.rs appartient à core, qui ne connaît pas de base de données ; la sonde qui en interroge une est contribuée par record à travers les slots de ce fichier — un import, un champ sur Dependencies, la sonde elle-même et ses tests. Un projet sans modèle garde donc l'endpoint et n'a rien à lister, et le jour où un module apporte sa propre dépendance, il ajoute sa sonde sans que core bouge. La sonde est un aller-retour, pas un coup d'œil au pool : un pool distribue une connexion que le serveur d'en face soit là ou non, si bien qu'un contrôle qui ne regarde que le pool annonce UP pendant toute une panne. Un test généré ferme le pool et exige le 503 ; un autre exige que la vivacité, elle, ne suive pas.
Les métriques sont étiquetées par la route matchée — /api/products/{id}, jamais /api/products/1 — donc le nombre de séries est borné par la taille du projet et non par son trafic. Les sondes de santé et /metrics lui-même sont exclus du comptage : une sonde de vivacité toutes les secondes serait sinon l'endpoint le plus fréquenté du service.
Identifiant de corrélation. Chaque requête porte un x-request-id, forgé si elle arrive sans, inscrit dans chaque ligne de journal de cette requête, et renvoyé sur la réponse. L'ordre des couches est le point délicat — poser l'identifiant avant que quoi que ce soit journalise, le propager après — donc il est écrit avec un ServiceBuilder, qui se lit de haut en bas dans l'ordre où la requête les traverse, et un test échoue si les trois se retrouvent dans le désordre.
La DoD, exécutée plutôt qu'affirmée. examples/observability/ contient un docker-compose.yml et un prometheus.yml, et le job CI scraped-by-prometheus les lance : il génère shop.cdl, le sert, monte Prometheus, et exige que la cible soit up et que axum_http_requests_total{endpoint="/api/products"} revienne de l'API de requête. Un projet généré n'est configuré pour rien de tout cela.
Décision — l'export OpenTelemetry n'est pas généré. La feuille de route le voulait « documenté en option » ; il l'est, dans le README généré et dans Architecture §3.10. Ce vers quoi un service envoie ses traces se décide là où il est déployé, pas dans son code, et un exportateur OTLP câblé par défaut serait une dépendance et une adresse que personne n'a demandées. Le point d'accroche existe : tracing est déjà le souscripteur par lequel tout passe, et request_span nomme déjà chaque requête.
Phase 10 — Blueprints & extensibilité🔗
Objectif : ouvrir Crabster à la personnalisation/extension communautaire, condition de sa valeur à long terme (cf. Vision, principe 7).
Tâches
- Implémenter la résolution en couches des templates (utilisateur > blueprint installé > cœur), déjà anticipée dans le moteur de la Phase 2 — cette phase la rend utilisable de bout en bout via la CLI (
crabster new --blueprint <crate-or-path>). - Documenter le format attendu d'un blueprint (structure de répertoire, métadonnées, conventions de nommage).
- Publier un blueprint d'exemple officiel (candidat : support Diesel en alternative à SeaORM, cf. Architecture §7) pour valider le mécanisme sur un cas réel non trivial.
- Mettre en place une convention de découverte (tag
crabster-blueprintsur crates.io + page listant les blueprints connus dans la doc).
Livrables
- Mécanisme de blueprint fonctionnel et documenté.
- Au moins un blueprint tiers/exemple publié et maintenu par le projet.
Definition of Done
- Un contributeur externe peut créer un blueprint qui modifie la génération (ex: remplacer un template) sans toucher au code du cœur Crabster, en suivant uniquement la documentation.
Résultat (2026-09-07) — phase terminée, une tâche ramenée à sa forme utile. Le mécanisme en couches marchait depuis la Phase 2 ; ce qui manquait était ce qui le rend utilisable par quelqu'un d'autre.
La page. Écrire un blueprint — les quatre choses qu'un blueprint peut faire, le manifeste champ par champ, les variables disponibles, les slots, les zones protégées, ce qu'un blueprint ne peut pas faire, et comment le distribuer. La DoD dit « en suivant uniquement la documentation », donc cette page est la livraison ; le reste l'appuie.
Le blueprint d'exemple : examples/blueprints/gitlab. Il fait les quatre choses en quatre petits fichiers — il ajoute un module que Crabster n'a jamais vu, allumé par un réglage ci gitlab que son propre manifeste réclame ; il ajoute .gitlab-ci.yml ; il écrit dans le README à travers un slot d'un fichier qu'il ne possède pas ; et il remplace le workflow GitHub par un template vide, ce qui l'efface. Le projet qu'il produit compile, est rustfmt-propre et passe Clippy en -D warnings, parce qu'un blueprint qui marche et produit un projet qui ne compile pas est un blueprint inutile. Un job CI le génère et vérifie les quatre à chaque exécution.
Ce n'est pas Diesel, et c'est délibéré. La feuille de route proposait Diesel « en candidat ». Réécrire la persistance, les migrations, les DTO, les handlers et la correspondance d'erreurs contre un autre ORM, testés sur trois bases, n'est pas une étape mais un projet — et l'exemple qui valide un mécanisme doit se lire d'une traite. GitLab a une propriété que Diesel n'a pas : il comble un trou que l'étape 7 a laissé sciemment (« on utilise une CI ou l'autre, générer les deux laisse un fichier mort »), et c'est précisément la forme de décision qu'un blueprint existe pour renverser chez ceux à qui elle ne convient pas.
Décision — le manifeste déclare la version de templates, et c'est obligatoire. Un blueprint écrit contre des slots, des variables et des noms de fichiers que les templates intégrés définissent, et ceux-là bougent : avant la 1.0, une version mineure peut changer n'importe lequel. Sans ce champ, un blueprint d'une version précédente échoue au fond d'un template sur un slot disparu, avec un message qui ne dit rien de la cause. Comparé sur majeur et mineur — le niveau de correctif ne change jamais un template. Ajouté maintenant, tant qu'aucun blueprint tiers n'existe : le rendre obligatoire plus tard casserait tous ceux écrits entre-temps. Les modules intégrés ne le déclarent pas, étant livrés dans le même binaire que le numéro auquel on les comparerait.
Décision — --blueprint prend un chemin, pas un nom de crate. La feuille de route écrivait <crate-or-path>. Résoudre un nom voudrait dire télécharger et exécuter des templates tiers depuis crates.io au moment de la génération : une question de chaîne d'approvisionnement qui mérite mieux qu'un effet de bord de cette phase. Cloner, ou cargo add puis pointer le chemin, met la même décision entre les mains de l'utilisateur en la rendant visible. Écrit dans la page plutôt que laissé implicite.
La convention de découverte est le mot-clé crabster-blueprint sur crates.io, et la page porte la table des blueprints connus, avec une ligne dedans. Publier sur crates.io n'est pas quelque chose que ce dépôt peut faire ; la convention et la table, si.
Phases 11 à 18 — Périmètre V2🔗
Le travail post-V1 est détaillé dans le périmètre V2 : cycle de vie du code généré (upgrade et fusion incrémentale), authentification OAuth2/OIDC, microservices, persistance étendue, rétro-ingénierie, frontend, Kubernetes et maturité de l'écosystème blueprints.
Deux points de vigilance à garder en tête pendant la V1, car ils conditionnent la faisabilité de la V2 :
- Les templates V1 doivent, dès leur écriture, séparer clairement le code purement généré du code destiné à être modifié par l'utilisateur — c'est ce qui rendra la fusion incrémentale de la Phase 11 abordable plutôt que douloureuse.
- Le contrat OpenAPI stabilisé en Phase 6 est la fondation de la génération de client typé de la Phase 16 : sa qualité en V1 détermine directement ce qui est possible en V2.
Phase C — Documentation utilisateur & communauté (continu, démarre dès la Phase 1)🔗
Objectif : Crabster n'a de valeur que si les gens s'en servent ; la documentation utilisateur n'est pas un artefact de fin de projet.
Tâches
- Site de documentation (mdBook ou équivalent Rust-natif) reprenant et enrichissant ce corpus au fil des phases.
- Guide de démarrage rapide ("5 minutes to a running API"), maintenu synchronisé avec le vrai comportement de la CLI (testé en CI si possible, sur le modèle des "doc tests" exécutables).
- Canal de communication communautaire (Discord/Zulip/Discussions GitHub — à trancher).
- Politique de contribution aux blueprints, vitrine des blueprints communautaires (dès Phase 10).
Definition of Done
- Un nouvel utilisateur peut suivre uniquement la documentation publiée pour générer et faire tourner son premier projet, sans lire le code source.
Risques transverses à surveiller🔗
- Imiter un générateur existant plutôt que concevoir : le risque est de transposer trait pour trait un outil d'un autre écosystème au lieu de chercher l'équivalent Rust idiomatique — arbitrer systématiquement en faveur de la Vision, principe 2.
- Complexité du mécanisme de fusion/upgrade : le point le plus délicat du domaine ; V1 l'esquive délibérément (génération one-shot + ajout simple), ne pas sous-estimer l'effort en Phase 11 de la V2.
- Dérive de scope vers le frontend avant que le backend soit solide : l'ADR-0001 doit être défendu activement si la pression communautaire pousse à ouvrir le chantier frontend prématurément — la V2 le traite en ADR-0003.
- Choix ORM (SeaORM) qui évolue vite : SeaORM est un projet plus jeune que Diesel ; suivre son évolution et garder Diesel documenté comme filet de sécurité via le mécanisme de blueprint (Phase 10). {% endraw %}