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:
@@ -69,6 +69,7 @@ temp/
|
||||
|
||||
# C++ build artifacts
|
||||
cpp/build/
|
||||
/build/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user