Personne n'aime écrire des expressions cron.
Sérieusement. 0 9 <em> </em> * n'est pas quelque chose qu'un humain devrait avoir à taper en 2026. C'est une notation conçue pour les ordinateurs en 1975, et chaque fois qu'un développeur doit chercher quel champ est le jour de la semaine et lequel est le mois, nous perdons collectivement une heure de productivité humaine. Nous avons envoyé des sondes sur Jupiter. Nous devrions pouvoir dire « every day at 9am » et laisser la machine résoudre le reste.
Cette conviction est devenue la fonctionnalité phare de 0cron.dev. Quand un utilisateur crée une tâche, il peut taper en anglais courant -- « every Monday at 2pm », « weekdays at 6am », « first day of the month at 9:30am » -- et le système le convertit en une expression cron valide en coulisse. Pas de documentation requise. Pas de jeu de devinettes à cinq champs.
Le parseur entier fait 152 lignes de Rust. Il utilise des regex. Pas un LLM. Pas un arbre de dépendances qui tire la moitié de crates.io. Juste du pattern matching contre les phrases que les gens utilisent réellement quand ils parlent de planifications.
Cet article parcourt chaque ligne de ce parseur, explique pourquoi nous avons fait les choix que nous avons faits, et montre pourquoi parfois l'approche la plus simple est la bonne.
La décision UX : pourquoi l'anglais courant compte
Avant de toucher au code, parlons de la décision produit.
0cron cible deux audiences : les développeurs à l'aise avec la syntaxe cron mais qui la trouvent pénible, et les utilisateurs semi-techniques (juniors DevOps, fondateurs de startups, freelances) qui savent qu'ils doivent exécuter un script sur un planning mais ne peuvent pas se rappeler si */5 signifie toutes les cinq minutes ou tous les cinq du mois.
Pour le premier groupe, nous acceptons toujours les expressions cron brutes. Si vous voulez taper 30 2 <em>/3 </em> 1-5, allez-y. Le parseur détecte si l'entrée ressemble à une expression cron et la laisse passer telle quelle.
Pour le second groupe -- et honnêtement, pour le premier aussi, parce que tout le monde préfère la clarté -- nous voulions quelque chose de radical : tapez ce que vous voulez dire en anglais, et ça fonctionne.
La question était : comment construire ce parseur ?
Pourquoi pas un LLM ?
La réponse évidente en 2026 : appeler un LLM. Envoyer « every Tuesday at 3pm » à GPT-4o ou DeepSeek, récupérer 0 15 <em> </em> 2, terminé.
Nous avons rejeté cette idée immédiatement, pour trois raisons :
Latence. Un appel LLM ajoute 200-800 ms à ce qui devrait être une interaction UI instantanée. Quand un utilisateur tape une planification et clique « Créer la tâche », il s'attend à une réponse en moins de 100 ms. Ajouter un aller-retour réseau vers une API d'inférence rend le produit paresseux.
Coût. 0cron coûte 1,99 $/mois avec des tâches illimitées. Si chaque création de tâche déclenche un appel LLM, même un bon marché à 0,001 $ par requête, un power user créant 500 tâches nous coûterait 0,50 $ -- un quart de son revenu d'abonnement -- juste pour le parsing de planification. L'économie ne fonctionne pas.
Déterminisme. Les expressions cron doivent être exactes. Il n'y a pas de place pour « l'interprétation créative ». Si un LLM parse occasionnellement « every Tuesday » comme 0 0 <em> </em> 3 au lieu de 0 0 <em> </em> 2 (parce qu'il a confondu la numérotation), la tâche de cet utilisateur s'exécute le mercredi et il perd confiance dans la plateforme. Une regex soit correspond, soit non. Il n'y a pas de risque d'hallucination.
Le bon outil pour ce travail n'est pas l'intelligence artificielle. C'est le pattern matching. Plus précisément, des expressions régulières appliquées à une chaîne d'entrée normalisée.
La fonction principale : 152 lignes de clarté
Voici le point d'entrée complet du parseur NLP. Chaque pattern de planification est géré par une seule fonction qui se lit de haut en bas, essaie chaque pattern dans l'ordre, et retourne soit une expression cron valide, soit un message d'erreur utile :
rustpub fn parse_natural_language(input: &str) -> AppResult<String> {
let input = input.trim().to_lowercase();
// "every minute"
if input == "every minute" {
return Ok("* * * * *".to_string());
}
// "every N minutes"
if let Some(caps) = re(r"^every (\d+) minutes?$").captures(&input) {
let n: u32 = caps[1].parse().unwrap_or(1);
return Ok(format!("*/{n} * * * *"));
}
// "every day at H:MMam/pm"
if let Some(caps) = re(r"^every day at (\d{1,2})(?::(\d{2}))?\s*(am|pm)$")
.captures(&input)
{
let (hour, minute) = parse_time(&caps[1], caps.get(2).map(|m| m.as_str()), &caps[3])?;
return Ok(format!("{minute} {hour} * * *"));
}
// "every [weekday] at H:MMam/pm"
if let Some(caps) = re(
r"^every (monday|tuesday|wednesday|thursday|friday|saturday|sunday) at (\d{1,2})(?::(\d{2}))?\s*(am|pm)$"
).captures(&input) {
let dow = weekday_to_cron(&caps[1]);
let (hour, minute) = parse_time(&caps[2], caps.get(3).map(|m| m.as_str()), &caps[4])?;
return Ok(format!("{minute} {hour} * * {dow}"));
}
// "weekdays at H:MMam/pm"
if let Some(caps) = re(r"^weekdays at (\d{1,2})(?::(\d{2}))?\s*(am|pm)$")
.captures(&input)
{
let (hour, minute) = parse_time(&caps[1], caps.get(2).map(|m| m.as_str()), &caps[3])?;
return Ok(format!("{minute} {hour} * * 1-5"));
}
// "first day of month at H:MMam/pm"
if let Some(caps) = re(
r"^first day of (?:the )?month at (\d{1,2})(?::(\d{2}))?\s*(am|pm)$"
).captures(&input) {
let (hour, minute) = parse_time(&caps[1], caps.get(2).map(|m| m.as_str()), &caps[3])?;
return Ok(format!("{minute} {hour} 1 * *"));
}
// "twice a day at Ham/pm and Ham/pm"
// ... and more patterns
Err(AppError::Validation(format!(
"Unrecognized schedule pattern: '{input}'. Try patterns like \
'every day at 9am', 'every Monday at 2pm', 'every 15 minutes', \
'weekdays at 6am'."
)))
}Quelques points à noter sur cette conception.
Lisibilité de haut en bas. Chaque pattern est un bloc autonome : un commentaire expliquant ce qu'il matche, une regex, l'extraction des groupes capturés, et une instruction return. Un nouveau développeur (ou une future version de Claude) peut scanner cette fonction en 30 secondes et comprendre chaque type de planification supporté par le système.
Retours anticipés. Chaque pattern retourne immédiatement s'il correspond. Il n'y a pas de machine à états, pas d'accumulateur, pas d'ambiguïté sur quel pattern a gagné. Le premier match gagne. C'est important parce que les patterns sont ordonnés du plus spécifique au plus général -- donc « every minute » (correspondance exacte) vient avant « every N minutes » (correspondance paramétrée).
Normalisation en amont. La toute première ligne fait input.trim().to_lowercase(). Cela signifie que nous n'avons jamais à nous soucier de « Every Day At 9AM » vs « every day at 9am » vs « every day at 9am ». Une seule étape de normalisation élimine une catégorie entière de cas limites.
Anatomie d'un pattern : la correspondance jour de la semaine
Disséquons un pattern en détail -- la correspondance « every [weekday] at H:MMam/pm », parce qu'elle exerce chaque partie du système :
rust// "every [weekday] at H:MMam/pm"
if let Some(caps) = re(
r"^every (monday|tuesday|wednesday|thursday|friday|saturday|sunday) at (\d{1,2})(?::(\d{2}))?\s*(am|pm)$"
).captures(&input) {
let dow = weekday_to_cron(&caps[1]);
let (hour, minute) = parse_time(&caps[2], caps.get(3).map(|m| m.as_str()), &caps[4])?;
return Ok(format!("{minute} {hour} * * {dow}"));
}La regex a cinq composants :
^every-- ancré au début, commence par le mot « every »(monday|tuesday|...)-- capture le nom du jour en groupe 1at (\d{1,2})-- capture l'heure (1-2 chiffres) en groupe 2(?::(\d{2}))?-- capture optionnellement les minutes après un deux-points en groupe 3. Le(?:...)?extérieur rend la partie minutes entièrement optionnelle, donc « 9am » et « 9:30am » fonctionnent tous les deux\s*(am|pm)$-- capture le méridien en groupe 4, ancré à la fin
Quand un utilisateur tape « every Tuesday at 2:30pm », la regex produit : - Groupe 1 : « tuesday » - Groupe 2 : « 2 » - Groupe 3 : « 30 » - Groupe 4 : « pm »
Puis weekday_to_cron("tuesday") retourne "2", parse_time("2", Some("30"), "pm") retourne (14, 30), et le résultat formaté est "30 14 <em> </em> 2" -- ce qui signifie « à 14 h 30 les mardis », exactement ce que l'utilisateur a demandé.
Le détail subtil est caps.get(3).map(|m| m.as_str()). Dans le crate regex de Rust, .get() retourne un Option<Match>, qui est None si le groupe optionnel n'a pas participé à la correspondance. C'est ainsi que nous gérons « every Tuesday at 2pm » (pas de minutes spécifiées) versus « every Tuesday at 2:30pm » (minutes spécifiées). La fonction parse_time reçoit None pour les minutes et les met à zéro par défaut.
Le helper parse_time : AM/PM sans douleur
Le parsing de l'heure semble trivial jusqu'à ce que vous vous rappeliez que 12am est minuit, 12pm est midi, et que tout le monde se trompe avec les deux. Voici le helper :
rustfn parse_time(hour_str: &str, min_str: Option<&str>, ampm: &str) -> AppResult<(u32, u32)> {
let mut hour: u32 = hour_str
.parse()
.map_err(|_| AppError::Validation("Invalid hour".to_string()))?;
let minute: u32 = min_str
.unwrap_or("0")
.parse()
.map_err(|_| AppError::Validation("Invalid minute".to_string()))?;
if ampm == "pm" && hour != 12 {
hour += 12;
} else if ampm == "am" && hour == 12 {
hour = 0;
}
Ok((hour, minute))
}La logique de conversion AM/PM a exactement deux cas spéciaux :
- PM et l'heure n'est pas 12 : Ajouter 12. Donc 2pm devient 14, 11pm devient 23. Mais 12pm reste 12 (midi).
- AM et l'heure est 12 : Mettre à 0. Donc 12am devient 0 (minuit). Toutes les autres heures AM restent telles quelles.
Toute autre combinaison passe sans changement : 9am reste 9, 12pm reste 12, 1pm devient 13. Quatre lignes de logique couvrent correctement toutes les conversions de format 12 heures vers 24 heures.
Le min_str.unwrap_or("0") gère le cas où les minutes n'ont pas été spécifiées. « Every day at 9am » devient heure 9, minute 0 -- ce qui formate l'expression cron 0 9 <em> </em> *.
Nous avons envisagé d'ajouter une validation (rejeter heure > 12, minute > 59), mais la regex contraint déjà l'entrée à des heures de 1-2 chiffres et exactement 2 chiffres pour les minutes. Si quelqu'un arrive à taper « every day at 99:99pm », la regex ne correspondra pas, et il obtiendra le message d'erreur de repli. La validation est structurelle, pas conditionnelle.
Mapping des jours de la semaine : simple mais correct
rustfn weekday_to_cron(day: &str) -> &str {
match day {
"sunday" => "0", "monday" => "1", "tuesday" => "2",
"wednesday" => "3", "thursday" => "4", "friday" => "5",
"saturday" => "6", _ => "0",
}
}Cette fonction mappe les noms de jours anglais aux numéros cron de jour de semaine. La seule subtilité est la convention de numérotation : dans le cron standard, dimanche est 0 et samedi est 6. Certaines implémentations cron utilisent aussi 7 pour dimanche, mais nous restons au standard POSIX parce que notre moteur de planification utilise le crate cron Rust, qui suit POSIX.
Le _ => "0" par défaut est un filet de sécurité qui ne devrait jamais se déclencher en pratique, parce que la regex appelante ne correspond qu'aux sept noms de jours exacts. Mais l'analyse d'exhaustivité des match de Rust nécessite de gérer tous les cas, et revenir à dimanche par défaut est un repli raisonnable.
Nous avons brièvement envisagé d'utiliser un enum pour les jours de la semaine -- enum Weekday { Monday, Tuesday, ... } -- mais cela aurait ajouté quinze lignes de boilerplate (impl FromStr, définitions de variants, formatage d'affichage) pour une fonction qui est appelée à exactement un endroit. Parfois, un statement match sur des chaînes est le bon niveau d'abstraction.
Des messages d'erreur qui enseignent
La branche finale du parseur est sans doute la plus importante pour l'expérience utilisateur :
rustErr(AppError::Validation(format!(
"Unrecognized schedule pattern: '{input}'. Try patterns like \
'every day at 9am', 'every Monday at 2pm', 'every 15 minutes', \
'weekdays at 6am'."
)))Ce n'est pas une erreur générique « Invalid input ». Elle fait trois choses :
- Renvoie l'entrée. L'utilisateur voit exactement quelle chaîne a été rejetée, ce qui aide au débogage (surtout s'il y avait un espace inattendu ou une faute de frappe).
- Fournit des exemples concrets. Au lieu de pointer vers la documentation, le message d'erreur lui-même contient quatre patterns fonctionnels. Un utilisateur peut copier un de ces exemples tel quel et le modifier.
- Couvre les cas d'usage les plus courants. Les quatre exemples représentent les quatre intentions les plus probables : quotidien à une heure précise, hebdomadaire un jour spécifique, intervalles périodiques, et planifications jours ouvrables.
Nous avons appris ce pattern des messages d'erreur des compilateurs. Les meilleurs compilateurs ne disent pas juste « syntax error on line 47 » -- ils disent « expected ; after expression, did you mean to add one here? ». Notre parseur NLP est un petit compilateur qui traduit l'anglais en cron, et il devrait avoir la même qualité de rapport d'erreurs.
Ce que nous ne supportons délibérément pas
Le parseur rejette certains patterns exprès, et chaque rejet était une décision produit consciente.
Planifications sub-minute
« Every 30 seconds » n'est explicitement pas supporté. Les expressions cron ont une granularité minimale d'une minute, et pour de bonnes raisons. Une tâche qui s'exécute toutes les 30 secondes génère 2 880 exécutions par jour. À cette fréquence, vous n'avez pas une tâche planifiée -- vous avez un service qui devrait tourner en continu. Nous préférons orienter les utilisateurs vers la bonne architecture plutôt que d'activer des patterns qui génèreront d'énormes volumes d'exécutions échouées et rempliront leurs logs.
Patterns multi-jours complexes
« Every Monday and Wednesday at 9am » n'est pas actuellement parsé. L'expression cron pour cela (0 9 <em> </em> 1,3) est simple, mais la regex pour parser des combinaisons de jours arbitraires devient compliquée rapidement. « Monday and Wednesday », « Monday, Wednesday, and Friday », « Tuesday through Thursday » -- chacun nécessite un pattern différent. Nous avons choisi de lancer avec le pattern de jour unique le plus courant et de laisser les power users entrer du cron brut pour les planifications multi-jours.
Planifications relatives
« Every 2 hours starting at 8am » implique un état -- le « starting at » nécessite de savoir quand la tâche a été créée ou exécutée pour la dernière fois. Cron n'a pas ce concept. 0 8-23/2 <em> </em> * est l'approximation la plus proche, mais ce n'est pas sémantiquement identique. Plutôt que de produire quelque chose qui correspond presque-mais-pas-tout-à-fait à l'intention de l'utilisateur, nous rejetons l'entrée et le laissons l'exprimer en syntaxe cron.
Qualificatifs de fuseau horaire
« Every day at 9am EST » n'est pas parsé parce que la gestion des fuseaux horaires est une préoccupation distincte. Le fuseau horaire de l'utilisateur est défini au niveau du compte, et toutes les planifications sont interprétées relativement à celui-ci. Permettre des surcharges de fuseau horaire par planification créerait de la confusion et des configurations conflictuelles.
Le helper regex : compiler une fois, matcher souvent
Un détail d'implémentation à noter est la fonction helper re() utilisée dans tout le parseur :
rustfn re(pattern: &str) -> Regex {
Regex::new(pattern).unwrap()
}Dans l'implémentation actuelle, cela compile une nouvelle Regex à chaque appel. Pour une fonction qui s'exécute une fois par création de tâche (pas dans une boucle chaude), c'est acceptable. La compilation prend des microsecondes, et le code est plus clair que de maintenir une map statique lazy_static! ou once_cell::Lazy de patterns pré-compilés.
Si le profilage montrait un jour ceci comme un goulot d'étranglement -- ce qui n'arriverait pas, parce que la création de tâche est une opération à basse fréquence -- nous passerions à LazyLock (stabilisé dans Rust 1.80) avec des patterns pré-compilés. Mais nous suivons le principe : ne pas optimiser ce qui n'a pas besoin d'être optimisé.
Intégration : où le parseur se situe dans la stack
Le parseur NLP est invoqué à deux endroits :
- Endpoint API de création de tâche. Quand un utilisateur soumet une nouvelle tâche avec une chaîne de planification, l'API essaie d'abord
parse_natural_language(). Si elle retourne une expression cron valide, cette expression est stockée dans la base de données. Si elle échoue, l'API essaie de parser l'entrée comme une expression cron brute. Si les deux échouent, l'utilisateur reçoit le message d'erreur pédagogique.
- Endpoint API de mise à jour de tâche. Même logique. Si un utilisateur modifie la planification d'une tâche de « every day at 9am » à « every Monday at 3pm », le parseur gère la conversion de manière transparente.
La sortie du parseur -- une expression cron standard à cinq champs -- est ce qui est stocké dans PostgreSQL et ce que le moteur de planification évalue. L'entrée en langage naturel n'est jamais stockée. Cela signifie que la base de données contient toujours des planifications normalisées et lisibles par la machine, et le parseur NLP est purement une couche de traduction à la frontière de l'API.
Cette séparation des préoccupations est délibérée. Le moteur de planification n'a pas besoin de savoir si une planification provient du langage naturel ou a été tapée comme expression cron brute. Il voit juste 0 9 <em> </em> * et déclenche la tâche à 9 h 00 UTC (ajusté au fuseau horaire de l'utilisateur).
Ce que nous avons appris
Construire ce parseur nous a enseigné quelques choses sur le développement produit.
Le contrôle du scope compte plus que la sophistication. Nous aurions pu passer deux semaines à construire un système complet de compréhension du langage naturel avec correspondance floue, correction orthographique, et résolution d'ambiguïté. Au lieu de cela, nous avons passé deux heures à écrire 152 lignes de regex. Le résultat couvre 90 % de ce que les utilisateurs tapent réellement, et le message d'erreur gère les 10 % restants avec grâce.
Regex n'est pas un gros mot. Il y a une blague célèbre : « Certaines personnes, confrontées à un problème, pensent "Je sais, je vais utiliser des expressions régulières." Maintenant elles ont deux problèmes. » Mais cette blague s'applique à l'utilisation de regex pour des tâches où c'est le mauvais outil -- parser du HTML, valider des adresses e-mail, matcher des structures imbriquées. Pour matcher un petit ensemble bien défini de phrases anglaises contre des patterns connus, regex est le bon outil. C'est rapide, déterministe, et testable.
Les messages d'erreur sont de l'UI. Le message d'erreur du parseur est souvent la première chose qu'un nouvel utilisateur voit après sa première tentative échouée. Si ce message dit « Invalid schedule », l'utilisateur est perdu. S'il dit « Try 'every day at 9am' », l'utilisateur réussit à sa deuxième tentative. Le message d'erreur n'est pas un cas limite -- c'est une partie du parcours heureux pour les nouveaux utilisateurs.
152 lignes n'est pas une limitation. Le parseur gère sept patterns de planification distincts, couvre la grande majorité des cas d'usage cron du monde réel, et tient dans un seul fichier que n'importe quel développeur Rust peut comprendre en cinq minutes. Si nous devons ajouter le support de « every hour » ou « twice a day », chaque nouveau pattern fait cinq à huit lignes de code. L'architecture scale linéairement avec le nombre de patterns, et il n'y a pas de falaise de complexité.
Ceci est la partie 4 d'une série de 10 articles sur la construction de 0cron.dev.
| # | Article | Focus |
|---|---|---|
| 1 | Pourquoi le monde a besoin d'un service cron à 2 $ | Analyse de marché et philosophie tarifaire |
| 2 | 4 agents, 1 produit : construire 0cron en une seule session | Build parallèle avec 4 agents Claude |
| 3 | Construire un moteur de planification cron en Rust | Axum, sorted sets Redis, exécuteur de tâches |
| 4 | "Tous les jours à 9 h" : parsing de planification en langage naturel | Cet article |
| 5 | Notifications multi-canaux : e-mail, Slack, Discord, Telegram, webhooks | Dispatch de notifications sur 5 canaux |
| 6 | Intégration Stripe pour un SaaS à 1,99 $/mois | Facturation, essais, et gestion de webhooks |
| 7 | Du HTML statique au tableau de bord SvelteKit en une nuit | Architecture frontend et runes Svelte 5 |
| 8 | Monitoring heartbeat : quand votre tâche devrait vous pinguer | Modèle moniteur, pings, et périodes de grâce |
| 9 | Secrets chiffrés, clés API, et sécurité | AES-256-GCM, authentification par clé API, signature HMAC |
| 10 | D'Abidjan à la production : lancement de 0cron.dev | L'histoire complète et la suite |