From c5fb6cab76897bbc87900b4074738893424a687a Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Wed, 3 Jun 2026 00:34:50 +0200 Subject: [PATCH] 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) --- .gitignore | 1 + functions/core/describe_cron_expr.go | 294 ++++++++++++++++++++++ functions/core/describe_cron_expr.md | 76 ++++++ functions/core/describe_cron_expr_test.go | 141 +++++++++++ 4 files changed, 512 insertions(+) create mode 100644 functions/core/describe_cron_expr.go create mode 100644 functions/core/describe_cron_expr.md create mode 100644 functions/core/describe_cron_expr_test.go diff --git a/.gitignore b/.gitignore index 07e1d17f..20cbe1c9 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,7 @@ temp/ # C++ build artifacts cpp/build/ +/build/ # OS .DS_Store diff --git a/functions/core/describe_cron_expr.go b/functions/core/describe_cron_expr.go new file mode 100644 index 00000000..33b70856 --- /dev/null +++ b/functions/core/describe_cron_expr.go @@ -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] + } +} diff --git a/functions/core/describe_cron_expr.md b/functions/core/describe_cron_expr.md new file mode 100644 index 00000000..cfae10f8 --- /dev/null +++ b/functions/core/describe_cron_expr.md @@ -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. diff --git a/functions/core/describe_cron_expr_test.go b/functions/core/describe_cron_expr_test.go new file mode 100644 index 00000000..2aff1219 --- /dev/null +++ b/functions/core/describe_cron_expr_test.go @@ -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) + } + }) + } +}