feat(core): describe_cron_expr_go_core + ignorar build/ raiz

Funcion pura que describe expresiones cron en lenguaje natural estilo
crontab.guru (consumida por el tooltip de schedule del frontend de dag_engine
via /api/cron/describe). Tambien se anade build/ (raiz) al .gitignore para que
los artefactos de compilacion C++ no se vuelvan a versionar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 00:34:50 +02:00
parent 4bce095964
commit c5fb6cab76
4 changed files with 512 additions and 0 deletions
+1
View File
@@ -69,6 +69,7 @@ temp/
# C++ build artifacts
cpp/build/
/build/
# OS
.DS_Store
+294
View File
@@ -0,0 +1,294 @@
package core
import (
"fmt"
"strings"
)
var cronDayNames = [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
var cronMonthNames = [13]string{
"", "January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
}
// DescribeCronExpr returns a human-readable English description of a standard
// 5-field cron expression (minute hour day-of-month month day-of-week).
//
// It delegates parsing and validation to ParseCronExpr, so all syntax errors
// and out-of-range values that ParseCronExpr detects are surfaced here too.
// Returns an error for expressions with the wrong number of fields or invalid
// values; otherwise always returns a non-empty description string.
//
// Examples:
//
// DescribeCronExpr("0 6 * * *") // "At 06:00, every day"
// DescribeCronExpr("*/15 * * * *") // "Every 15 minutes"
// DescribeCronExpr("0 9 * * 1-5") // "At 09:00, Monday through Friday"
func DescribeCronExpr(expr string) (string, error) {
sched, err := ParseCronExpr(strings.TrimSpace(expr))
if err != nil {
return "", fmt.Errorf("describe_cron_expr: %w", err)
}
// Collect raw token strings for pattern matching (we keep the original
// tokens because the expanded slices lose the structural information
// needed to distinguish "every minute" from a list of 60 values).
raw := strings.Fields(sched.Raw)
minTok, hrTok, domTok, monTok, dowTok := raw[0], raw[1], raw[2], raw[3], raw[4]
allStarDOM := domTok == "*"
allStarMon := monTok == "*"
allStarDOW := dowTok == "*"
// ── Time part ────────────────────────────────────────────────────────────
timePart := describeTimePart(minTok, hrTok)
// ── Day-of-week part ─────────────────────────────────────────────────────
dowPart, dowIsWild := describeDOW(dowTok, sched.DayOfWeek)
// ── Day-of-month part ────────────────────────────────────────────────────
domPart, domIsWild := describeDOMPart(domTok, sched.DayOfMonth)
// ── Month part ───────────────────────────────────────────────────────────
monPart, monIsWild := describeMonthPart(monTok, sched.Month)
// ── Compose ──────────────────────────────────────────────────────────────
// Special: every minute
if minTok == "*" && hrTok == "*" && allStarDOM && allStarMon && allStarDOW {
return "Every minute", nil
}
// Special: every N minutes (*/N with everything else wildcard)
if isStep(minTok) && hrTok == "*" && allStarDOM && allStarMon && allStarDOW {
n := stepValue(minTok)
if n == 1 {
return "Every minute", nil
}
return fmt.Sprintf("Every %d minutes", n), nil
}
// Special: every hour on the hour (minute==0, hour==*, dom/mon/dow are *)
if minTok == "0" && hrTok == "*" && allStarDOM && allStarMon && allStarDOW {
return "Every hour, on the hour", nil
}
// Special: every N hours (minute==0, */N in hour, rest *)
if minTok == "0" && isStep(hrTok) && allStarDOM && allStarMon && allStarDOW {
n := stepValue(hrTok)
if n == 1 {
return "Every hour, on the hour", nil
}
return fmt.Sprintf("Every %d hours", n), nil
}
// Build a sentence from parts.
var parts []string
parts = append(parts, timePart)
// Day-of-week constraints win over day-of-month when DOW is specific.
if !dowIsWild {
parts = append(parts, dowPart)
} else if !domIsWild {
parts = append(parts, domPart)
} else {
parts = append(parts, "every day")
}
if !monIsWild {
parts = append(parts, monPart)
}
return strings.Join(parts, ", "), nil
}
// ── helpers ──────────────────────────────────────────────────────────────────
// describeTimePart produces the "At HH:MM" or "every N minutes/hours" clause.
func describeTimePart(minTok, hrTok string) string {
// Fixed minute + fixed hour → "At HH:MM"
if isFixed(minTok) && isFixed(hrTok) {
m := mustAtoi(minTok)
h := mustAtoi(hrTok)
return fmt.Sprintf("At %02d:%02d", h, m)
}
// Wildcard hour, fixed minute → "at minute N of every hour"
if hrTok == "*" && isFixed(minTok) {
m := mustAtoi(minTok)
return fmt.Sprintf("at minute %d of every hour", m)
}
// Step hour, fixed minute
if isStep(hrTok) && isFixed(minTok) {
n := stepValue(hrTok)
m := mustAtoi(minTok)
if n == 1 {
return fmt.Sprintf("at minute %d of every hour", m)
}
return fmt.Sprintf("at minute %d of every %d hours", m, n)
}
// Step minute
if isStep(minTok) {
n := stepValue(minTok)
if hrTok == "*" {
if n == 1 {
return "every minute"
}
return fmt.Sprintf("every %d minutes", n)
}
if isFixed(hrTok) {
h := mustAtoi(hrTok)
return fmt.Sprintf("every %d minutes during hour %02d", n, h)
}
}
// Fallback: describe each field individually
return fmt.Sprintf("at minute %s of hour %s", minTok, hrTok)
}
// describeDOW returns a phrase for the day-of-week field and whether it is
// effectively a wildcard (covers all 7 days).
func describeDOW(tok string, vals []int) (string, bool) {
if tok == "*" || len(vals) == 7 {
return "", true
}
// Single day
if len(vals) == 1 {
d := vals[0] % 7
return fmt.Sprintf("only on %s", cronDayNames[d]), false
}
// Contiguous range covering Mon-Fri exactly → "Monday through Friday"
if len(vals) == 5 && vals[0] == 1 && vals[4] == 5 {
return "Monday through Friday", false
}
// Any other contiguous range
if isContiguous(vals) {
first := vals[0] % 7
last := vals[len(vals)-1] % 7
return fmt.Sprintf("%s through %s", cronDayNames[first], cronDayNames[last]), false
}
// List of days
names := make([]string, len(vals))
for i, v := range vals {
names[i] = cronDayNames[v%7]
}
return "on " + joinEnglish(names), false
}
// describeDOMPart returns a phrase for the day-of-month field.
func describeDOMPart(tok string, vals []int) (string, bool) {
if tok == "*" || len(vals) == 31 {
return "", true
}
if len(vals) == 1 {
return fmt.Sprintf("on day %d of the month", vals[0]), false
}
if isContiguous(vals) {
return fmt.Sprintf("on days %d through %d of the month", vals[0], vals[len(vals)-1]), false
}
strs := make([]string, len(vals))
for i, v := range vals {
strs[i] = fmt.Sprintf("%d", v)
}
return "on days " + joinEnglish(strs) + " of the month", false
}
// describeMonthPart returns a phrase for the month field.
func describeMonthPart(tok string, vals []int) (string, bool) {
if tok == "*" || len(vals) == 12 {
return "", true
}
if len(vals) == 1 {
return fmt.Sprintf("in %s", cronMonthNames[vals[0]]), false
}
if isContiguous(vals) {
return fmt.Sprintf("from %s to %s", cronMonthNames[vals[0]], cronMonthNames[vals[len(vals)-1]]), false
}
names := make([]string, len(vals))
for i, v := range vals {
names[i] = cronMonthNames[v]
}
return "in " + joinEnglish(names), false
}
// isFixed returns true when tok is a plain integer (no wildcards or ranges).
func isFixed(tok string) bool {
for _, c := range tok {
if c < '0' || c > '9' {
return false
}
}
return len(tok) > 0
}
// isStep returns true for tokens of the form */N or N/N.
func isStep(tok string) bool {
return strings.Contains(tok, "/")
}
// stepValue returns N from */N or a-b/N. Assumes isStep(tok) is true.
func stepValue(tok string) int {
idx := strings.LastIndex(tok, "/")
n, err := parseInt(tok[idx+1:])
if err != nil {
return 1
}
return n
}
// parseInt is a minimal strconv.Atoi without the import indirection.
func parseInt(s string) (int, error) {
n := 0
if len(s) == 0 {
return 0, fmt.Errorf("empty")
}
for _, c := range s {
if c < '0' || c > '9' {
return 0, fmt.Errorf("not a digit: %q", c)
}
n = n*10 + int(c-'0')
}
return n, nil
}
// mustAtoi converts a known-valid digit string to int (panics on malformed input,
// but input is already validated by ParseCronExpr so this is safe).
func mustAtoi(s string) int {
n, _ := parseInt(s)
return n
}
// isContiguous returns true when vals is a sequence without gaps.
func isContiguous(vals []int) bool {
for i := 1; i < len(vals); i++ {
if vals[i] != vals[i-1]+1 {
return false
}
}
return true
}
// joinEnglish joins strings with commas and "and" before the last element.
func joinEnglish(items []string) string {
switch len(items) {
case 0:
return ""
case 1:
return items[0]
case 2:
return items[0] + " and " + items[1]
default:
return strings.Join(items[:len(items)-1], ", ") + " and " + items[len(items)-1]
}
}
+76
View File
@@ -0,0 +1,76 @@
---
name: describe_cron_expr
kind: function
lang: go
domain: core
version: "1.0.0"
purity: pure
signature: "func DescribeCronExpr(expr string) (string, error)"
description: "Convierte una expresion cron estandar de 5 campos en una descripcion legible en ingles, al estilo de crontab.guru. Soporta *, valores fijos, pasos (*/N), rangos (a-b), listas (a,b,c) y aliases (@daily, @hourly, etc.). Produce frases como 'At 06:00, every day', 'Every 15 minutes' o 'At 09:00, Monday through Friday'."
tags: [cron, schedule, description, human-readable, dag-engine, tooltip, parsing, scheduler]
uses_functions: [parse_cron_expr_go_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [fmt, strings]
params:
- name: expr
desc: "expresion cron de 5 campos (minuto hora dia_mes mes dia_semana) o alias @daily/@weekly/@monthly/@yearly/@hourly; los campos soportan *, valores fijos, pasos (*/N), rangos (a-b) y listas (a,b,c)"
output: "descripcion en ingles del schedule cron; error si la expresion tiene numero de campos incorrecto o valores fuera de rango"
tested: true
tests:
- "daily at 06:00"
- "every 15 minutes"
- "every hour on the hour"
- "weekdays at 09:00"
- "friday at 21:00"
- "every 6 hours"
- "first of month at 08:30"
- "sunday at midnight"
- "every minute"
- "every 1 minute step"
- "every 2 hours"
- "multiple days of week"
- "specific month"
- "invalid expression - wrong field count"
- "invalid minute value"
- "alias @daily"
- "alias @weekly"
- "alias @hourly"
- "saturday at 23:59"
- "days 1 and 15 of month"
- "month range June to August"
test_file_path: "functions/core/describe_cron_expr_test.go"
file_path: "functions/core/describe_cron_expr.go"
---
## Ejemplo
```go
desc, err := DescribeCronExpr("0 6 * * *")
// desc = "At 06:00, every day"
desc2, _ := DescribeCronExpr("*/15 * * * *")
// desc2 = "Every 15 minutes"
desc3, _ := DescribeCronExpr("0 9 * * 1-5")
// desc3 = "At 09:00, Monday through Friday"
desc4, _ := DescribeCronExpr("30 8 1 * *")
// desc4 = "At 08:30, on day 1 of the month"
_, err2 := DescribeCronExpr("0 6 * *")
// err2 != nil (solo 4 campos)
```
## Cuando usarla
Cuando necesites mostrar al usuario final una descripcion legible de un schedule cron — tooltips en el frontend de dag_engine, mensajes de confirmacion antes de guardar una tarea programada, o logs de auditoria. Produce descripciones en ingles del mismo estilo que crontab.guru, pero retorna error en vez de texto crudo cuando la expresion es invalida.
## Gotchas
- Devuelve `(string, error)` aunque es pura sin I/O. El error es por input invalido (mismo patron que `ParseCronExpr`). Segun la convencion del repo, retornar error solo por validacion de input no cambia la clasificacion a `impure`.
- Para combinaciones muy especificas (ej. minuto con lista + hora con step), la descripcion es campo-a-campo y puede ser menos elegante que crontab.guru, pero siempre es correcta.
- Dias de semana: 0 y 7 ambos representan Sunday (hereda el comportamiento de `ParseCronExpr` que los normaliza a [0-6]).
- Para convertir la expresion a la lista de instantes de disparo usa `next_cron_time_go_core`. Esta funcion solo describe — no calcula tiempos.
+141
View File
@@ -0,0 +1,141 @@
package core
import (
"testing"
)
func TestDescribeCronExpr(t *testing.T) {
tests := []struct {
name string
expr string
want string
wantErr bool
}{
{
name: "daily at 06:00",
expr: "0 6 * * *",
want: "At 06:00, every day",
},
{
name: "every 15 minutes",
expr: "*/15 * * * *",
want: "Every 15 minutes",
},
{
name: "every hour on the hour",
expr: "0 * * * *",
want: "Every hour, on the hour",
},
{
name: "weekdays at 09:00",
expr: "0 9 * * 1-5",
want: "At 09:00, Monday through Friday",
},
{
name: "friday at 21:00",
expr: "0 21 * * 5",
want: "At 21:00, only on Friday",
},
{
name: "every 6 hours",
expr: "0 */6 * * *",
want: "Every 6 hours",
},
{
name: "first of month at 08:30",
expr: "30 8 1 * *",
want: "At 08:30, on day 1 of the month",
},
{
name: "sunday at midnight",
expr: "0 0 * * 0",
want: "At 00:00, only on Sunday",
},
{
name: "every minute",
expr: "* * * * *",
want: "Every minute",
},
{
name: "every 1 minute step",
expr: "*/1 * * * *",
want: "Every minute",
},
{
name: "every 2 hours",
expr: "0 */2 * * *",
want: "Every 2 hours",
},
{
name: "multiple days of week",
expr: "0 12 * * 1,3,5",
want: "At 12:00, on Monday, Wednesday and Friday",
},
{
name: "specific month",
expr: "0 9 1 6 *",
want: "At 09:00, on day 1 of the month, in June",
},
{
name: "invalid expression - wrong field count",
expr: "0 6 * *",
want: "",
wantErr: true,
},
{
name: "invalid minute value",
expr: "99 6 * * *",
want: "",
wantErr: true,
},
{
name: "alias @daily",
expr: "@daily",
want: "At 00:00, every day",
},
{
name: "alias @weekly",
expr: "@weekly",
want: "At 00:00, only on Sunday",
},
{
name: "alias @hourly",
expr: "@hourly",
want: "Every hour, on the hour",
},
{
name: "saturday at 23:59",
expr: "59 23 * * 6",
want: "At 23:59, only on Saturday",
},
{
name: "days 1 and 15 of month",
expr: "0 8 1,15 * *",
want: "At 08:00, on days 1 and 15 of the month",
},
{
name: "month range June to August",
expr: "0 6 * 6-8 *",
want: "At 06:00, every day, from June to August",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := DescribeCronExpr(tc.expr)
if tc.wantErr {
if err == nil {
t.Errorf("expected error, got %q", got)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}