ruki parser

This commit is contained in:
booleanmaybe 2026-04-01 22:58:09 -04:00
parent 814494751d
commit 1bf6bfa064
21 changed files with 5296 additions and 30 deletions

View file

@ -312,25 +312,11 @@ func (tc *TaskController) SaveStatus(statusDisplay string) bool {
// SaveType saves the new type to the current task after validating the display value.
// Returns true if the type was successfully updated, false otherwise.
func (tc *TaskController) SaveType(typeDisplay string) bool {
// Parse type display back to TaskType
var newType taskpkg.Type
typeFound := false
for _, t := range []taskpkg.Type{
taskpkg.TypeStory,
taskpkg.TypeBug,
taskpkg.TypeSpike,
taskpkg.TypeEpic,
} {
if taskpkg.TypeDisplay(t) == typeDisplay {
newType = t
typeFound = true
break
}
}
if !typeFound {
newType = taskpkg.NormalizeType(typeDisplay)
// reverse the display string ("Bug 💥") back to a canonical key ("bug")
newType, ok := taskpkg.ParseDisplay(typeDisplay)
if !ok {
slog.Warn("unrecognized type display", "display", typeDisplay)
return false
}
// Validate using TypeValidator

View file

@ -222,7 +222,7 @@ func TestTaskController_SaveType(t *testing.T) {
setupTask: func(tc *TaskController, s store.Store) {
tc.SetDraft(newTestTask())
},
typeDisplay: "Bug",
typeDisplay: task.TypeDisplay(task.TypeBug),
wantType: task.TypeBug,
wantSuccess: true,
},
@ -233,25 +233,25 @@ func TestTaskController_SaveType(t *testing.T) {
_ = s.CreateTask(t)
tc.StartEditSession(t.ID)
},
typeDisplay: "Spike",
typeDisplay: task.TypeDisplay(task.TypeSpike),
wantType: task.TypeSpike,
wantSuccess: true,
},
{
name: "invalid type normalizes to default",
name: "invalid type is rejected",
setupTask: func(tc *TaskController, s store.Store) {
tc.SetDraft(newTestTask())
},
typeDisplay: "InvalidType",
wantType: task.TypeStory, // NormalizeType defaults to story
wantSuccess: true,
wantType: task.TypeStory, // task type unchanged from setup
wantSuccess: false,
},
{
name: "no active task",
setupTask: func(tc *TaskController, s store.Store) {
// Don't set up any task
},
typeDisplay: "Story",
typeDisplay: task.TypeDisplay(task.TypeStory),
wantSuccess: false,
},
}

1
go.mod
View file

@ -23,6 +23,7 @@ require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/alecthomas/participle/v2 v2.1.4 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect

3
go.sum
View file

@ -9,8 +9,11 @@ github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNx
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=

190
ruki/ast.go Normal file
View file

@ -0,0 +1,190 @@
package ruki
import "time"
// --- top-level union types ---
// Statement is the result of parsing a CRUD command.
// Exactly one variant is non-nil.
type Statement struct {
Select *SelectStmt
Create *CreateStmt
Update *UpdateStmt
Delete *DeleteStmt
}
// SelectStmt represents "select [where <condition>]".
type SelectStmt struct {
Where Condition // nil = select all
}
// CreateStmt represents "create <field>=<value>...".
type CreateStmt struct {
Assignments []Assignment
}
// UpdateStmt represents "update where <condition> set <field>=<value>...".
type UpdateStmt struct {
Where Condition
Set []Assignment
}
// DeleteStmt represents "delete where <condition>".
type DeleteStmt struct {
Where Condition
}
// --- triggers ---
// Trigger is the result of parsing a reactive rule.
type Trigger struct {
Timing string // "before" or "after"
Event string // "create", "update", or "delete"
Where Condition // optional guard (nil if omitted)
Action *Statement // after-triggers only (create/update/delete, not select)
Run *RunAction // after-triggers only (alternative to Action)
Deny *string // before-triggers only
}
// RunAction represents "run(<string-expr>)" as a top-level trigger action.
type RunAction struct {
Command Expr
}
// --- conditions ---
// Condition is the interface for all boolean condition nodes.
type Condition interface {
conditionNode()
}
// BinaryCondition represents "<condition> and/or <condition>".
type BinaryCondition struct {
Op string // "and" or "or"
Left Condition
Right Condition
}
// NotCondition represents "not <condition>".
type NotCondition struct {
Inner Condition
}
// CompareExpr represents "<expr> <op> <expr>".
type CompareExpr struct {
Left Expr
Op string // "=", "!=", "<", ">", "<=", ">="
Right Expr
}
// IsEmptyExpr represents "<expr> is [not] empty".
type IsEmptyExpr struct {
Expr Expr
Negated bool // true = "is not empty"
}
// InExpr represents "<value> [not] in <collection>".
type InExpr struct {
Value Expr
Collection Expr
Negated bool // true = "not in"
}
// QuantifierExpr represents "<expr> any/all <condition>".
type QuantifierExpr struct {
Expr Expr
Kind string // "any" or "all"
Condition Condition
}
func (*BinaryCondition) conditionNode() {}
func (*NotCondition) conditionNode() {}
func (*CompareExpr) conditionNode() {}
func (*IsEmptyExpr) conditionNode() {}
func (*InExpr) conditionNode() {}
func (*QuantifierExpr) conditionNode() {}
// --- expressions ---
// Expr is the interface for all expression nodes.
type Expr interface {
exprNode()
}
// FieldRef represents a bare field name like "status" or "priority".
type FieldRef struct {
Name string
}
// QualifiedRef represents "old.field" or "new.field".
type QualifiedRef struct {
Qualifier string // "old" or "new"
Name string
}
// StringLiteral represents a double-quoted string value.
type StringLiteral struct {
Value string
}
// IntLiteral represents an integer value.
type IntLiteral struct {
Value int
}
// DateLiteral represents a YYYY-MM-DD date.
type DateLiteral struct {
Value time.Time
}
// DurationLiteral represents a number+unit like "2day" or "1week".
type DurationLiteral struct {
Value int
Unit string
}
// ListLiteral represents ["a", "b", ...].
type ListLiteral struct {
Elements []Expr
}
// EmptyLiteral represents the "empty" keyword.
type EmptyLiteral struct{}
// FunctionCall represents "name(args...)".
type FunctionCall struct {
Name string
Args []Expr
}
// BinaryExpr represents "<expr> +/- <expr>".
type BinaryExpr struct {
Op string // "+" or "-"
Left Expr
Right Expr
}
// SubQuery represents "select [where <condition>]" used inside count().
type SubQuery struct {
Where Condition // nil = select all
}
func (*FieldRef) exprNode() {}
func (*QualifiedRef) exprNode() {}
func (*StringLiteral) exprNode() {}
func (*IntLiteral) exprNode() {}
func (*DateLiteral) exprNode() {}
func (*DurationLiteral) exprNode() {}
func (*ListLiteral) exprNode() {}
func (*EmptyLiteral) exprNode() {}
func (*FunctionCall) exprNode() {}
func (*BinaryExpr) exprNode() {}
func (*SubQuery) exprNode() {}
// --- assignments ---
// Assignment represents "field=value" in create/update statements.
type Assignment struct {
Field string
Value Expr
}

173
ruki/grammar.go Normal file
View file

@ -0,0 +1,173 @@
package ruki
// grammar.go — unexported participle grammar structs.
// these encode operator precedence via grammar layering.
// consumers never see these; lower.go converts them to clean AST types.
// --- top-level statement grammar ---
type statementGrammar struct {
Select *selectGrammar `parser:" @@"`
Create *createGrammar `parser:"| @@"`
Update *updateGrammar `parser:"| @@"`
Delete *deleteGrammar `parser:"| @@"`
}
type selectGrammar struct {
Where *orCond `parser:"'select' ( 'where' @@ )?"`
}
type createGrammar struct {
Assignments []assignmentGrammar `parser:"'create' @@+"`
}
type updateGrammar struct {
Where orCond `parser:"'update' 'where' @@"`
Set []assignmentGrammar `parser:"'set' @@+"`
}
type deleteGrammar struct {
Where orCond `parser:"'delete' 'where' @@"`
}
type assignmentGrammar struct {
Field string `parser:"@Ident '='"`
Value exprGrammar `parser:"@@"`
}
// --- trigger grammar ---
type triggerGrammar struct {
Timing string `parser:"@( 'before' | 'after' )"`
Event string `parser:"@( 'create' | 'update' | 'delete' )"`
Where *orCond `parser:"( 'where' @@ )?"`
Action *actionGrammar `parser:"( @@"`
Deny *denyGrammar `parser:"| @@ )?"`
}
type actionGrammar struct {
Run *runGrammar `parser:" @@"`
Create *createGrammar `parser:"| @@"`
Update *updateGrammar `parser:"| @@"`
Delete *deleteGrammar `parser:"| @@"`
}
type runGrammar struct {
Command exprGrammar `parser:"'run' '(' @@ ')'"`
}
type denyGrammar struct {
Message string `parser:"'deny' @String"`
}
// --- condition grammar (precedence layers) ---
// orCond is the lowest-precedence condition layer.
type orCond struct {
Left andCond `parser:"@@"`
Right []andCond `parser:"( 'or' @@ )*"`
}
type andCond struct {
Left notCond `parser:"@@"`
Right []notCond `parser:"( 'and' @@ )*"`
}
type notCond struct {
Not *notCond `parser:" 'not' @@"`
Primary *primaryCond `parser:"| @@"`
}
type primaryCond struct {
Paren *orCond `parser:" '(' @@ ')'"`
Expr *exprCond `parser:"| @@"`
}
// exprCond parses an expression followed by a condition operator.
type exprCond struct {
Left exprGrammar `parser:"@@"`
Compare *compareTail `parser:"( @@"`
IsEmpty *isEmptyTail `parser:"| @@"`
IsNotEmpty *isNotEmptyTail `parser:"| @@"`
NotIn *notInTail `parser:"| @@"`
In *inTail `parser:"| @@"`
Any *quantifierTail `parser:"| @@"`
All *allQuantTail `parser:"| @@ )?"`
}
type compareTail struct {
Op string `parser:"@CompareOp"`
Right exprGrammar `parser:"@@"`
}
type isEmptyTail struct {
Is string `parser:"@'is' 'empty'"`
}
type isNotEmptyTail struct {
Is string `parser:"@'is' 'not' 'empty'"`
}
type inTail struct {
Collection exprGrammar `parser:"'in' @@"`
}
type notInTail struct {
Collection exprGrammar `parser:"'not' 'in' @@"`
}
type quantifierTail struct {
Condition primaryCond `parser:"'any' @@"`
}
type allQuantTail struct {
Condition primaryCond `parser:"'all' @@"`
}
// --- expression grammar ---
type exprGrammar struct {
Left unaryExpr `parser:"@@"`
Tail []exprBinTail `parser:"@@*"`
}
type exprBinTail struct {
Op string `parser:"@( Plus | Minus )"`
Right unaryExpr `parser:"@@"`
}
type unaryExpr struct {
FuncCall *funcCallExpr `parser:" @@"`
SubQuery *subQueryExpr `parser:"| @@"`
QualRef *qualRefExpr `parser:"| @@"`
ListLit *listLitExpr `parser:"| @@"`
StrLit *string `parser:"| @String"`
DateLit *string `parser:"| @Date"`
DurLit *string `parser:"| @Duration"`
IntLit *int `parser:"| @Int"`
Empty *emptyExpr `parser:"| @@"`
FieldRef *string `parser:"| @Ident"`
Paren *exprGrammar `parser:"| '(' @@ ')'"`
}
type funcCallExpr struct {
Name string `parser:"@Ident '('"`
Args []exprGrammar `parser:"( @@ ( ',' @@ )* )? ')'"`
}
type subQueryExpr struct {
Where *orCond `parser:"'select' ( 'where' @@ )?"`
}
type qualRefExpr struct {
Qualifier string `parser:"@( 'old' | 'new' ) '.'"`
Name string `parser:"@Ident"`
}
type listLitExpr struct {
Elements []exprGrammar `parser:"'[' ( @@ ( ',' @@ )* )? ']'"`
}
type emptyExpr struct {
Keyword string `parser:"@'empty'"`
}

24
ruki/lexer.go Normal file
View file

@ -0,0 +1,24 @@
package ruki
import "github.com/alecthomas/participle/v2/lexer"
// rukiLexer defines the token rules for the ruki DSL.
// rule ordering is critical: longer/more-specific patterns first.
var rukiLexer = lexer.MustSimple([]lexer.SimpleRule{
{Name: "Comment", Pattern: `--[^\n]*`},
{Name: "Whitespace", Pattern: `\s+`},
{Name: "Duration", Pattern: `\d+(?:sec|min|hour|day|week|month|year)s?`},
{Name: "Date", Pattern: `\d{4}-\d{2}-\d{2}`},
{Name: "Int", Pattern: `\d+`},
{Name: "String", Pattern: `"(?:[^"\\]|\\.)*"`},
{Name: "CompareOp", Pattern: `!=|<=|>=|[=<>]`},
{Name: "Plus", Pattern: `\+`},
{Name: "Minus", Pattern: `-`},
{Name: "Dot", Pattern: `\.`},
{Name: "LParen", Pattern: `\(`},
{Name: "RParen", Pattern: `\)`},
{Name: "LBracket", Pattern: `\[`},
{Name: "RBracket", Pattern: `\]`},
{Name: "Comma", Pattern: `,`},
{Name: "Ident", Pattern: `[a-zA-Z_][a-zA-Z0-9_]*`},
})

383
ruki/lower.go Normal file
View file

@ -0,0 +1,383 @@
package ruki
import (
"fmt"
"strconv"
"strings"
"time"
)
// lower.go converts participle grammar structs into clean AST types.
func lowerStatement(g *statementGrammar) (*Statement, error) {
switch {
case g.Select != nil:
s, err := lowerSelect(g.Select)
if err != nil {
return nil, err
}
return &Statement{Select: s}, nil
case g.Create != nil:
s, err := lowerCreate(g.Create)
if err != nil {
return nil, err
}
return &Statement{Create: s}, nil
case g.Update != nil:
s, err := lowerUpdate(g.Update)
if err != nil {
return nil, err
}
return &Statement{Update: s}, nil
case g.Delete != nil:
s, err := lowerDelete(g.Delete)
if err != nil {
return nil, err
}
return &Statement{Delete: s}, nil
default:
return nil, fmt.Errorf("empty statement")
}
}
func lowerSelect(g *selectGrammar) (*SelectStmt, error) {
var where Condition
if g.Where != nil {
var err error
where, err = lowerOrCond(g.Where)
if err != nil {
return nil, err
}
}
return &SelectStmt{Where: where}, nil
}
func lowerCreate(g *createGrammar) (*CreateStmt, error) {
assignments, err := lowerAssignments(g.Assignments)
if err != nil {
return nil, err
}
return &CreateStmt{Assignments: assignments}, nil
}
func lowerUpdate(g *updateGrammar) (*UpdateStmt, error) {
where, err := lowerOrCond(&g.Where)
if err != nil {
return nil, err
}
set, err := lowerAssignments(g.Set)
if err != nil {
return nil, err
}
return &UpdateStmt{Where: where, Set: set}, nil
}
func lowerDelete(g *deleteGrammar) (*DeleteStmt, error) {
where, err := lowerOrCond(&g.Where)
if err != nil {
return nil, err
}
return &DeleteStmt{Where: where}, nil
}
func lowerAssignments(gs []assignmentGrammar) ([]Assignment, error) {
result := make([]Assignment, len(gs))
for i, g := range gs {
val, err := lowerExpr(&g.Value)
if err != nil {
return nil, err
}
result[i] = Assignment{Field: g.Field, Value: val}
}
return result, nil
}
// --- trigger lowering ---
func lowerTrigger(g *triggerGrammar) (*Trigger, error) {
t := &Trigger{
Timing: g.Timing,
Event: g.Event,
}
if g.Where != nil {
where, err := lowerOrCond(g.Where)
if err != nil {
return nil, err
}
t.Where = where
}
if g.Action != nil {
if err := lowerTriggerAction(g.Action, t); err != nil {
return nil, err
}
}
if g.Deny != nil {
msg := unquoteString(g.Deny.Message)
t.Deny = &msg
}
return t, nil
}
func lowerTriggerAction(g *actionGrammar, t *Trigger) error {
switch {
case g.Run != nil:
cmd, err := lowerExpr(&g.Run.Command)
if err != nil {
return err
}
t.Run = &RunAction{Command: cmd}
case g.Create != nil:
s, err := lowerCreate(g.Create)
if err != nil {
return err
}
t.Action = &Statement{Create: s}
case g.Update != nil:
s, err := lowerUpdate(g.Update)
if err != nil {
return err
}
t.Action = &Statement{Update: s}
case g.Delete != nil:
s, err := lowerDelete(g.Delete)
if err != nil {
return err
}
t.Action = &Statement{Delete: s}
default:
return fmt.Errorf("empty trigger action")
}
return nil
}
// --- condition lowering ---
func lowerOrCond(g *orCond) (Condition, error) {
left, err := lowerAndCond(&g.Left)
if err != nil {
return nil, err
}
for _, r := range g.Right {
right, err := lowerAndCond(&r)
if err != nil {
return nil, err
}
left = &BinaryCondition{Op: "or", Left: left, Right: right}
}
return left, nil
}
func lowerAndCond(g *andCond) (Condition, error) {
left, err := lowerNotCond(&g.Left)
if err != nil {
return nil, err
}
for _, r := range g.Right {
right, err := lowerNotCond(&r)
if err != nil {
return nil, err
}
left = &BinaryCondition{Op: "and", Left: left, Right: right}
}
return left, nil
}
func lowerNotCond(g *notCond) (Condition, error) {
if g.Not != nil {
inner, err := lowerNotCond(g.Not)
if err != nil {
return nil, err
}
return &NotCondition{Inner: inner}, nil
}
return lowerPrimaryCond(g.Primary)
}
func lowerPrimaryCond(g *primaryCond) (Condition, error) {
if g.Paren != nil {
return lowerOrCond(g.Paren)
}
return lowerExprCond(g.Expr)
}
func lowerExprCond(g *exprCond) (Condition, error) {
left, err := lowerExpr(&g.Left)
if err != nil {
return nil, err
}
switch {
case g.Compare != nil:
right, err := lowerExpr(&g.Compare.Right)
if err != nil {
return nil, err
}
return &CompareExpr{Left: left, Op: g.Compare.Op, Right: right}, nil
case g.IsEmpty != nil:
return &IsEmptyExpr{Expr: left, Negated: false}, nil
case g.IsNotEmpty != nil:
return &IsEmptyExpr{Expr: left, Negated: true}, nil
case g.In != nil:
coll, err := lowerExpr(&g.In.Collection)
if err != nil {
return nil, err
}
return &InExpr{Value: left, Collection: coll, Negated: false}, nil
case g.NotIn != nil:
coll, err := lowerExpr(&g.NotIn.Collection)
if err != nil {
return nil, err
}
return &InExpr{Value: left, Collection: coll, Negated: true}, nil
case g.Any != nil:
cond, err := lowerPrimaryCond(&g.Any.Condition)
if err != nil {
return nil, err
}
return &QuantifierExpr{Expr: left, Kind: "any", Condition: cond}, nil
case g.All != nil:
cond, err := lowerPrimaryCond(&g.All.Condition)
if err != nil {
return nil, err
}
return &QuantifierExpr{Expr: left, Kind: "all", Condition: cond}, nil
default:
// bare expression used as condition — this is a parse error
return nil, fmt.Errorf("expression used as condition without comparison operator")
}
}
// --- expression lowering ---
func lowerExpr(g *exprGrammar) (Expr, error) {
left, err := lowerUnary(&g.Left)
if err != nil {
return nil, err
}
for _, tail := range g.Tail {
right, err := lowerUnary(&tail.Right)
if err != nil {
return nil, err
}
left = &BinaryExpr{Op: tail.Op, Left: left, Right: right}
}
return left, nil
}
func lowerUnary(g *unaryExpr) (Expr, error) {
switch {
case g.FuncCall != nil:
return lowerFuncCall(g.FuncCall)
case g.SubQuery != nil:
return lowerSubQuery(g.SubQuery)
case g.QualRef != nil:
return &QualifiedRef{Qualifier: g.QualRef.Qualifier, Name: g.QualRef.Name}, nil
case g.ListLit != nil:
return lowerListLit(g.ListLit)
case g.StrLit != nil:
return &StringLiteral{Value: unquoteString(*g.StrLit)}, nil
case g.DateLit != nil:
return parseDateLiteral(*g.DateLit)
case g.DurLit != nil:
return parseDurationLiteral(*g.DurLit)
case g.IntLit != nil:
return &IntLiteral{Value: *g.IntLit}, nil
case g.Empty != nil:
return &EmptyLiteral{}, nil
case g.FieldRef != nil:
return &FieldRef{Name: *g.FieldRef}, nil
case g.Paren != nil:
return lowerExpr(g.Paren)
default:
return nil, fmt.Errorf("empty expression")
}
}
func lowerFuncCall(g *funcCallExpr) (Expr, error) {
args := make([]Expr, len(g.Args))
for i, a := range g.Args {
arg, err := lowerExpr(&a)
if err != nil {
return nil, err
}
args[i] = arg
}
return &FunctionCall{Name: g.Name, Args: args}, nil
}
func lowerSubQuery(g *subQueryExpr) (Expr, error) {
var where Condition
if g.Where != nil {
var err error
where, err = lowerOrCond(g.Where)
if err != nil {
return nil, err
}
}
return &SubQuery{Where: where}, nil
}
func lowerListLit(g *listLitExpr) (Expr, error) {
elems := make([]Expr, len(g.Elements))
for i, e := range g.Elements {
elem, err := lowerExpr(&e)
if err != nil {
return nil, err
}
elems[i] = elem
}
return &ListLiteral{Elements: elems}, nil
}
// --- literal helpers ---
func unquoteString(s string) string {
// strip surrounding quotes and unescape
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
unquoted, err := strconv.Unquote(s)
if err == nil {
return unquoted
}
// fallback: just strip quotes
return s[1 : len(s)-1]
}
return s
}
func parseDateLiteral(s string) (Expr, error) {
t, err := time.Parse("2006-01-02", s)
if err != nil {
return nil, fmt.Errorf("invalid date literal %q: %w", s, err)
}
return &DateLiteral{Value: t}, nil
}
func parseDurationLiteral(s string) (Expr, error) {
// find where digits end and unit begins
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if i == 0 || i == len(s) {
return nil, fmt.Errorf("invalid duration literal %q", s)
}
val, err := strconv.Atoi(s[:i])
if err != nil {
return nil, fmt.Errorf("invalid duration value in %q: %w", s, err)
}
unit := strings.TrimSuffix(s[i:], "s") // normalize "days" → "day"
return &DurationLiteral{Value: val, Unit: unit}, nil
}

101
ruki/parser.go Normal file
View file

@ -0,0 +1,101 @@
package ruki
import (
"github.com/alecthomas/participle/v2"
)
// Schema provides the canonical field catalog and normalization functions
// that the parser uses for validation. Production code adapts this from
// workflow.Fields(), workflow.StatusRegistry, and workflow.TypeRegistry.
type Schema interface {
// Field returns the field spec for a given field name.
Field(name string) (FieldSpec, bool)
// NormalizeStatus validates and normalizes a raw status string.
// returns the canonical key and true, or ("", false) for unknown values.
NormalizeStatus(raw string) (string, bool)
// NormalizeType validates and normalizes a raw type string.
// returns the canonical key and true, or ("", false) for unknown values.
NormalizeType(raw string) (string, bool)
}
// ValueType identifies the semantic type of a field in the DSL.
type ValueType int
const (
ValueString ValueType = iota
ValueInt // priority, points
ValueDate // due
ValueTimestamp // createdAt, updatedAt
ValueDuration // duration literals
ValueBool // contains() return type
ValueID // task identifier
ValueRef // reference to another task
ValueRecurrence // recurrence pattern
ValueListString // tags
ValueListRef // dependsOn
ValueStatus // status enum
ValueTaskType // type enum
)
// FieldSpec describes a single task field for the parser.
type FieldSpec struct {
Name string
Type ValueType
}
// Parser parses ruki DSL statements and triggers.
type Parser struct {
stmtParser *participle.Parser[statementGrammar]
triggerParser *participle.Parser[triggerGrammar]
schema Schema
qualifiers qualifierPolicy // set before each validation pass
}
// NewParser constructs a Parser with the given schema for validation.
// panics if the grammar is invalid (programming error, not user error).
func NewParser(schema Schema) *Parser {
opts := []participle.Option{
participle.Lexer(rukiLexer),
participle.Elide("Comment", "Whitespace"),
participle.UseLookahead(3),
}
return &Parser{
stmtParser: participle.MustBuild[statementGrammar](opts...),
triggerParser: participle.MustBuild[triggerGrammar](opts...),
schema: schema,
}
}
// ParseStatement parses a CRUD statement and returns a validated AST.
func (p *Parser) ParseStatement(input string) (*Statement, error) {
g, err := p.stmtParser.ParseString("", input)
if err != nil {
return nil, err
}
stmt, err := lowerStatement(g)
if err != nil {
return nil, err
}
p.qualifiers = noQualifiers
if err := p.validateStatement(stmt); err != nil {
return nil, err
}
return stmt, nil
}
// ParseTrigger parses a reactive trigger rule and returns a validated AST.
func (p *Parser) ParseTrigger(input string) (*Trigger, error) {
g, err := p.triggerParser.ParseString("", input)
if err != nil {
return nil, err
}
trig, err := lowerTrigger(g)
if err != nil {
return nil, err
}
p.qualifiers = triggerQualifiers(trig.Event)
if err := p.validateTrigger(trig); err != nil {
return nil, err
}
return trig, nil
}

633
ruki/parser_test.go Normal file
View file

@ -0,0 +1,633 @@
package ruki
import (
"testing"
"time"
)
// testSchema implements Schema for tests with standard tiki fields.
type testSchema struct{}
func (testSchema) Field(name string) (FieldSpec, bool) {
fields := map[string]FieldSpec{
"id": {Name: "id", Type: ValueID},
"title": {Name: "title", Type: ValueString},
"description": {Name: "description", Type: ValueString},
"status": {Name: "status", Type: ValueStatus},
"type": {Name: "type", Type: ValueTaskType},
"tags": {Name: "tags", Type: ValueListString},
"dependsOn": {Name: "dependsOn", Type: ValueListRef},
"due": {Name: "due", Type: ValueDate},
"recurrence": {Name: "recurrence", Type: ValueRecurrence},
"assignee": {Name: "assignee", Type: ValueString},
"priority": {Name: "priority", Type: ValueInt},
"points": {Name: "points", Type: ValueInt},
"createdBy": {Name: "createdBy", Type: ValueString},
"createdAt": {Name: "createdAt", Type: ValueTimestamp},
"updatedAt": {Name: "updatedAt", Type: ValueTimestamp},
}
f, ok := fields[name]
return f, ok
}
func (testSchema) NormalizeStatus(raw string) (string, bool) {
valid := map[string]string{
"backlog": "backlog",
"ready": "ready",
"todo": "ready",
"in progress": "in_progress",
"in_progress": "in_progress",
"review": "review",
"done": "done",
"cancelled": "cancelled",
}
canonical, ok := valid[raw]
return canonical, ok
}
func (testSchema) NormalizeType(raw string) (string, bool) {
valid := map[string]string{
"story": "story",
"feature": "story",
"task": "story",
"bug": "bug",
"spike": "spike",
"epic": "epic",
}
canonical, ok := valid[raw]
return canonical, ok
}
func newTestParser() *Parser {
return NewParser(testSchema{})
}
func TestParseSelect(t *testing.T) {
p := newTestParser()
tests := []struct {
name string
input string
wantWhere bool
}{
{"select all", "select", false},
{"select with where", `select where status = "done"`, true},
{"select with and", `select where status = "done" and priority <= 2`, true},
{"select with in", `select where "bug" in tags`, true},
{"select with quantifier", `select where dependsOn any status != "done"`, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stmt, err := p.ParseStatement(tt.input)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if stmt.Select == nil {
t.Fatal("expected Select, got nil")
}
if tt.wantWhere && stmt.Select.Where == nil {
t.Fatal("expected Where condition, got nil")
}
if !tt.wantWhere && stmt.Select.Where != nil {
t.Fatal("expected nil Where, got condition")
}
})
}
}
func TestParseCreate(t *testing.T) {
p := newTestParser()
tests := []struct {
name string
input string
wantFields int
}{
{
"basic create",
`create title="Fix login" priority=2 status="ready" tags=["bug"]`,
4,
},
{
"single field",
`create title="hello"`,
1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stmt, err := p.ParseStatement(tt.input)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if stmt.Create == nil {
t.Fatal("expected Create, got nil")
}
if len(stmt.Create.Assignments) != tt.wantFields {
t.Fatalf("expected %d assignments, got %d", tt.wantFields, len(stmt.Create.Assignments))
}
})
}
}
func TestParseUpdate(t *testing.T) {
p := newTestParser()
tests := []struct {
name string
input string
wantSet int
}{
{
"update by id",
`update where id = "TIKI-ABC123" set status="done"`,
1,
},
{
"update with complex where",
`update where status = "ready" and "sprint-3" in tags set status="cancelled"`,
1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stmt, err := p.ParseStatement(tt.input)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if stmt.Update == nil {
t.Fatal("expected Update, got nil")
}
if len(stmt.Update.Set) != tt.wantSet {
t.Fatalf("expected %d set assignments, got %d", tt.wantSet, len(stmt.Update.Set))
}
})
}
}
func TestParseDelete(t *testing.T) {
p := newTestParser()
tests := []struct {
name string
input string
}{
{"delete by id", `delete where id = "TIKI-ABC123"`},
{"delete with complex where", `delete where status = "cancelled" and "old" in tags`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stmt, err := p.ParseStatement(tt.input)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if stmt.Delete == nil {
t.Fatal("expected Delete, got nil")
}
if stmt.Delete.Where == nil {
t.Fatal("expected Where condition, got nil")
}
})
}
}
func TestParseExpressions(t *testing.T) {
p := newTestParser()
tests := []struct {
name string
input string
check func(t *testing.T, stmt *Statement)
}{
{
"string literal in assignment",
`create title="hello world"`,
func(t *testing.T, stmt *Statement) {
t.Helper()
sl, ok := stmt.Create.Assignments[0].Value.(*StringLiteral)
if !ok {
t.Fatalf("expected StringLiteral, got %T", stmt.Create.Assignments[0].Value)
}
if sl.Value != "hello world" {
t.Fatalf("expected %q, got %q", "hello world", sl.Value)
}
},
},
{
"int literal in assignment",
`create title="x" priority=2`,
func(t *testing.T, stmt *Statement) {
t.Helper()
il, ok := stmt.Create.Assignments[1].Value.(*IntLiteral)
if !ok {
t.Fatalf("expected IntLiteral, got %T", stmt.Create.Assignments[1].Value)
}
if il.Value != 2 {
t.Fatalf("expected 2, got %d", il.Value)
}
},
},
{
"date literal in assignment",
`create title="x" due=2026-03-25`,
func(t *testing.T, stmt *Statement) {
t.Helper()
dl, ok := stmt.Create.Assignments[1].Value.(*DateLiteral)
if !ok {
t.Fatalf("expected DateLiteral, got %T", stmt.Create.Assignments[1].Value)
}
expected := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC)
if !dl.Value.Equal(expected) {
t.Fatalf("expected %v, got %v", expected, dl.Value)
}
},
},
{
"list literal in assignment",
`create title="x" tags=["bug", "frontend"]`,
func(t *testing.T, stmt *Statement) {
t.Helper()
ll, ok := stmt.Create.Assignments[1].Value.(*ListLiteral)
if !ok {
t.Fatalf("expected ListLiteral, got %T", stmt.Create.Assignments[1].Value)
}
if len(ll.Elements) != 2 {
t.Fatalf("expected 2 elements, got %d", len(ll.Elements))
}
},
},
{
"empty literal in assignment",
`create title="x" assignee=empty`,
func(t *testing.T, stmt *Statement) {
t.Helper()
if _, ok := stmt.Create.Assignments[1].Value.(*EmptyLiteral); !ok {
t.Fatalf("expected EmptyLiteral, got %T", stmt.Create.Assignments[1].Value)
}
},
},
{
"function call in assignment",
`create title="x" due=next_date(recurrence)`,
func(t *testing.T, stmt *Statement) {
t.Helper()
fc, ok := stmt.Create.Assignments[1].Value.(*FunctionCall)
if !ok {
t.Fatalf("expected FunctionCall, got %T", stmt.Create.Assignments[1].Value)
}
if fc.Name != "next_date" {
t.Fatalf("expected next_date, got %s", fc.Name)
}
if len(fc.Args) != 1 {
t.Fatalf("expected 1 arg, got %d", len(fc.Args))
}
},
},
{
"binary plus expression",
`create title="x" tags=tags + ["new"]`,
func(t *testing.T, stmt *Statement) {
t.Helper()
be, ok := stmt.Create.Assignments[1].Value.(*BinaryExpr)
if !ok {
t.Fatalf("expected BinaryExpr, got %T", stmt.Create.Assignments[1].Value)
}
if be.Op != "+" {
t.Fatalf("expected +, got %s", be.Op)
}
},
},
{
"duration literal",
`create title="x" due=2026-03-25 + 2day`,
func(t *testing.T, stmt *Statement) {
t.Helper()
be, ok := stmt.Create.Assignments[1].Value.(*BinaryExpr)
if !ok {
t.Fatalf("expected BinaryExpr, got %T", stmt.Create.Assignments[1].Value)
}
dur, ok := be.Right.(*DurationLiteral)
if !ok {
t.Fatalf("expected DurationLiteral, got %T", be.Right)
}
if dur.Value != 2 || dur.Unit != "day" {
t.Fatalf("expected 2day, got %d%s", dur.Value, dur.Unit)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stmt, err := p.ParseStatement(tt.input)
if err != nil {
t.Fatalf("parse error: %v", err)
}
tt.check(t, stmt)
})
}
}
func TestParseConditions(t *testing.T) {
p := newTestParser()
tests := []struct {
name string
input string
check func(t *testing.T, stmt *Statement)
}{
{
"simple compare",
`select where status = "done"`,
func(t *testing.T, stmt *Statement) {
t.Helper()
cmp, ok := stmt.Select.Where.(*CompareExpr)
if !ok {
t.Fatalf("expected CompareExpr, got %T", stmt.Select.Where)
}
if cmp.Op != "=" {
t.Fatalf("expected =, got %s", cmp.Op)
}
},
},
{
"is empty",
`select where assignee is empty`,
func(t *testing.T, stmt *Statement) {
t.Helper()
ie, ok := stmt.Select.Where.(*IsEmptyExpr)
if !ok {
t.Fatalf("expected IsEmptyExpr, got %T", stmt.Select.Where)
}
if ie.Negated {
t.Fatal("expected Negated=false")
}
},
},
{
"is not empty",
`select where description is not empty`,
func(t *testing.T, stmt *Statement) {
t.Helper()
ie, ok := stmt.Select.Where.(*IsEmptyExpr)
if !ok {
t.Fatalf("expected IsEmptyExpr, got %T", stmt.Select.Where)
}
if !ie.Negated {
t.Fatal("expected Negated=true")
}
},
},
{
"value in field",
`select where "bug" in tags`,
func(t *testing.T, stmt *Statement) {
t.Helper()
in, ok := stmt.Select.Where.(*InExpr)
if !ok {
t.Fatalf("expected InExpr, got %T", stmt.Select.Where)
}
if in.Negated {
t.Fatal("expected Negated=false")
}
},
},
{
"value not in list",
`select where status not in ["done", "cancelled"]`,
func(t *testing.T, stmt *Statement) {
t.Helper()
in, ok := stmt.Select.Where.(*InExpr)
if !ok {
t.Fatalf("expected InExpr, got %T", stmt.Select.Where)
}
if !in.Negated {
t.Fatal("expected Negated=true")
}
},
},
{
"and precedence",
`select where status = "done" and priority <= 2`,
func(t *testing.T, stmt *Statement) {
t.Helper()
bc, ok := stmt.Select.Where.(*BinaryCondition)
if !ok {
t.Fatalf("expected BinaryCondition, got %T", stmt.Select.Where)
}
if bc.Op != "and" {
t.Fatalf("expected and, got %s", bc.Op)
}
},
},
{
"or precedence — and binds tighter",
`select where priority = 1 or priority = 2 and status = "done"`,
func(t *testing.T, stmt *Statement) {
t.Helper()
// should parse as: priority=1 or (priority=2 and status="done")
bc, ok := stmt.Select.Where.(*BinaryCondition)
if !ok {
t.Fatalf("expected BinaryCondition, got %T", stmt.Select.Where)
}
if bc.Op != "or" {
t.Fatalf("expected or at top, got %s", bc.Op)
}
// right side should be an and
right, ok := bc.Right.(*BinaryCondition)
if !ok {
t.Fatalf("expected BinaryCondition on right, got %T", bc.Right)
}
if right.Op != "and" {
t.Fatalf("expected and on right, got %s", right.Op)
}
},
},
{
"not condition",
`select where not status = "done"`,
func(t *testing.T, stmt *Statement) {
t.Helper()
nc, ok := stmt.Select.Where.(*NotCondition)
if !ok {
t.Fatalf("expected NotCondition, got %T", stmt.Select.Where)
}
if _, ok := nc.Inner.(*CompareExpr); !ok {
t.Fatalf("expected CompareExpr inside not, got %T", nc.Inner)
}
},
},
{
"parenthesized condition",
`select where (status = "done" or status = "cancelled") and priority = 1`,
func(t *testing.T, stmt *Statement) {
t.Helper()
bc, ok := stmt.Select.Where.(*BinaryCondition)
if !ok {
t.Fatalf("expected BinaryCondition, got %T", stmt.Select.Where)
}
if bc.Op != "and" {
t.Fatalf("expected and at top, got %s", bc.Op)
}
// left should be an or (the parenthesized group)
left, ok := bc.Left.(*BinaryCondition)
if !ok {
t.Fatalf("expected BinaryCondition on left, got %T", bc.Left)
}
if left.Op != "or" {
t.Fatalf("expected or on left, got %s", left.Op)
}
},
},
{
"quantifier any",
`select where dependsOn any status != "done"`,
func(t *testing.T, stmt *Statement) {
t.Helper()
qe, ok := stmt.Select.Where.(*QuantifierExpr)
if !ok {
t.Fatalf("expected QuantifierExpr, got %T", stmt.Select.Where)
}
if qe.Kind != "any" {
t.Fatalf("expected any, got %s", qe.Kind)
}
},
},
{
"quantifier all",
`select where dependsOn all status = "done"`,
func(t *testing.T, stmt *Statement) {
t.Helper()
qe, ok := stmt.Select.Where.(*QuantifierExpr)
if !ok {
t.Fatalf("expected QuantifierExpr, got %T", stmt.Select.Where)
}
if qe.Kind != "all" {
t.Fatalf("expected all, got %s", qe.Kind)
}
},
},
{
"quantifier binds to primary — and separates",
`select where dependsOn any status != "done" and priority = 1`,
func(t *testing.T, stmt *Statement) {
t.Helper()
// should parse as: (dependsOn any (status != "done")) and (priority = 1)
bc, ok := stmt.Select.Where.(*BinaryCondition)
if !ok {
t.Fatalf("expected BinaryCondition at top, got %T", stmt.Select.Where)
}
if bc.Op != "and" {
t.Fatalf("expected and, got %s", bc.Op)
}
if _, ok := bc.Left.(*QuantifierExpr); !ok {
t.Fatalf("expected QuantifierExpr on left, got %T", bc.Left)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stmt, err := p.ParseStatement(tt.input)
if err != nil {
t.Fatalf("parse error: %v", err)
}
tt.check(t, stmt)
})
}
}
func TestParseQualifiedRefs(t *testing.T) {
p := newTestParser()
input := `select where status = "done"`
stmt, err := p.ParseStatement(input)
if err != nil {
t.Fatalf("parse error: %v", err)
}
cmp, ok := stmt.Select.Where.(*CompareExpr)
if !ok {
t.Fatalf("expected CompareExpr, got %T", stmt.Select.Where)
}
fr, ok := cmp.Left.(*FieldRef)
if !ok {
t.Fatalf("expected FieldRef, got %T", cmp.Left)
}
if fr.Name != "status" {
t.Fatalf("expected status, got %s", fr.Name)
}
}
func TestParseSubQuery(t *testing.T) {
p := newTestParser()
input := `select where count(select where status = "in progress" and assignee = "bob") >= 3`
stmt, err := p.ParseStatement(input)
if err != nil {
t.Fatalf("parse error: %v", err)
}
cmp, ok := stmt.Select.Where.(*CompareExpr)
if !ok {
t.Fatalf("expected CompareExpr, got %T", stmt.Select.Where)
}
fc, ok := cmp.Left.(*FunctionCall)
if !ok {
t.Fatalf("expected FunctionCall, got %T", cmp.Left)
}
if fc.Name != "count" {
t.Fatalf("expected count, got %s", fc.Name)
}
sq, ok := fc.Args[0].(*SubQuery)
if !ok {
t.Fatalf("expected SubQuery arg, got %T", fc.Args[0])
}
if sq.Where == nil {
t.Fatal("expected SubQuery Where, got nil")
}
}
func TestParseStatementErrors(t *testing.T) {
p := newTestParser()
tests := []struct {
name string
input string
}{
{"empty input", ""},
{"unknown keyword", "drop where id = 1"},
{"missing where in update", `update set status="done"`},
{"missing set in update", `update where id = "x"`},
{"missing where in delete", `delete id = "x"`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := p.ParseStatement(tt.input)
if err == nil {
t.Fatal("expected error, got nil")
}
})
}
}
func TestParseComment(t *testing.T) {
p := newTestParser()
input := `-- this is a comment
select where status = "done"`
stmt, err := p.ParseStatement(input)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if stmt.Select == nil {
t.Fatal("expected Select")
}
}

290
ruki/trigger_test.go Normal file
View file

@ -0,0 +1,290 @@
package ruki
import "testing"
func TestParseTrigger_BeforeDeny(t *testing.T) {
p := newTestParser()
tests := []struct {
name string
input string
event string
}{
{
"block completion with open deps",
`before update where new.status = "done" and dependsOn any status != "done" deny "cannot complete task with open dependencies"`,
"update",
},
{
"deny delete high priority",
`before delete where old.priority <= 2 deny "cannot delete high priority tasks"`,
"delete",
},
{
"require description for high priority",
`before update where new.priority <= 2 and new.description is empty deny "high priority tasks need a description"`,
"update",
},
{
"require description for stories",
`before create where new.type = "story" and new.description is empty deny "stories must have a description"`,
"create",
},
{
"prevent skipping review",
`before update where old.status = "in progress" and new.status = "done" deny "tasks must go through review before completion"`,
"update",
},
{
"protect high priority from demotion",
`before update where old.priority = 1 and old.status = "in progress" and new.priority > 1 deny "cannot demote priority of active critical tasks"`,
"update",
},
{
"no empty epics",
`before update where new.status = "done" and new.type = "epic" and blocks(new.id) is empty deny "epic has no dependencies"`,
"update",
},
{
"WIP limit",
`before update where new.status = "in progress" and count(select where assignee = new.assignee and status = "in progress") >= 3 deny "WIP limit reached for this assignee"`,
"update",
},
{
"points required before start",
`before update where new.status = "in progress" and new.points = 0 deny "tasks must be estimated before starting work"`,
"update",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
trig, err := p.ParseTrigger(tt.input)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if trig.Timing != "before" {
t.Fatalf("expected before, got %s", trig.Timing)
}
if trig.Event != tt.event {
t.Fatalf("expected %s, got %s", tt.event, trig.Event)
}
if trig.Deny == nil {
t.Fatal("expected Deny, got nil")
}
if trig.Action != nil {
t.Fatal("expected nil Action in before-trigger")
}
})
}
}
func TestParseTrigger_AfterAction(t *testing.T) {
p := newTestParser()
tests := []struct {
name string
input string
event string
wantCreate bool
wantUpdate bool
wantDelete bool
wantRun bool
}{
{
"recurring task create next",
`after update where new.status = "done" and old.recurrence is not empty create title=old.title priority=old.priority tags=old.tags recurrence=old.recurrence due=next_date(old.recurrence) status="ready"`,
"update",
true, false, false, false,
},
{
"recurring task clear recurrence",
`after update where new.status = "done" and old.recurrence is not empty update where id = old.id set recurrence=empty`,
"update",
false, true, false, false,
},
{
"auto assign urgent",
`after create where new.priority <= 2 and new.assignee is empty update where id = new.id set assignee="booleanmaybe"`,
"create",
false, true, false, false,
},
{
"cascade epic completion",
`after update where new.status = "done" update where id in blocks(old.id) and type = "epic" and dependsOn all status = "done" set status="done"`,
"update",
false, true, false, false,
},
{
"reopen epic on regression",
`after update where old.status = "done" and new.status != "done" update where id in blocks(old.id) and type = "epic" and status = "done" set status="in progress"`,
"update",
false, true, false, false,
},
{
"auto tag bugs",
`after create where new.type = "bug" update where id = new.id set tags=new.tags + ["needs-triage"]`,
"create",
false, true, false, false,
},
{
"propagate cancellation",
`after update where new.status = "cancelled" update where id in blocks(old.id) and status in ["backlog", "ready"] set status="cancelled"`,
"update",
false, true, false, false,
},
{
"unblock on last blocker",
`after update where new.status = "done" update where old.id in dependsOn and dependsOn all status = "done" and status = "backlog" set status="ready"`,
"update",
false, true, false, false,
},
{
"cleanup on delete",
`after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]`,
"delete",
false, true, false, false,
},
{
"auto delete stale",
`after update where new.status = "done" and old.updatedAt < now() - 2day delete where id = old.id`,
"update",
false, false, true, false,
},
{
"run action",
`after update where new.status = "in progress" and "claude" in new.tags run("claude -p 'implement tiki " + old.id + "'")`,
"update",
false, false, false, true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
trig, err := p.ParseTrigger(tt.input)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if trig.Timing != "after" {
t.Fatalf("expected after, got %s", trig.Timing)
}
if trig.Event != tt.event {
t.Fatalf("expected %s, got %s", tt.event, trig.Event)
}
if trig.Deny != nil {
t.Fatal("expected nil Deny in after-trigger")
}
if tt.wantRun {
if trig.Run == nil {
t.Fatal("expected Run action, got nil")
}
} else {
if trig.Action == nil {
t.Fatal("expected Action, got nil")
}
if tt.wantCreate && trig.Action.Create == nil {
t.Fatal("expected Create action")
}
if tt.wantUpdate && trig.Action.Update == nil {
t.Fatal("expected Update action")
}
if tt.wantDelete && trig.Action.Delete == nil {
t.Fatal("expected Delete action")
}
}
})
}
}
func TestParseTrigger_StructuralErrors(t *testing.T) {
p := newTestParser()
tests := []struct {
name string
input string
}{
{
"before with action",
`before update where new.status = "done" update where id = old.id set status="done"`,
},
{
"after with deny",
`after update where new.status = "done" deny "no"`,
},
{
"before without deny",
`before update where new.status = "done"`,
},
{
"after without action",
`after update where new.status = "done"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := p.ParseTrigger(tt.input)
if err == nil {
t.Fatal("expected error, got nil")
}
})
}
}
func TestParseTrigger_QualifiedRefsInWhere(t *testing.T) {
p := newTestParser()
input := `before update where old.status = "in progress" and new.status = "done" deny "skip"`
trig, err := p.ParseTrigger(input)
if err != nil {
t.Fatalf("parse error: %v", err)
}
bc, ok := trig.Where.(*BinaryCondition)
if !ok {
t.Fatalf("expected BinaryCondition, got %T", trig.Where)
}
// check left side has old.status
leftCmp, ok := bc.Left.(*CompareExpr)
if !ok {
t.Fatalf("expected CompareExpr on left, got %T", bc.Left)
}
qr, ok := leftCmp.Left.(*QualifiedRef)
if !ok {
t.Fatalf("expected QualifiedRef, got %T", leftCmp.Left)
}
if qr.Qualifier != "old" || qr.Name != "status" {
t.Fatalf("expected old.status, got %s.%s", qr.Qualifier, qr.Name)
}
// check right side has new.status
rightCmp, ok := bc.Right.(*CompareExpr)
if !ok {
t.Fatalf("expected CompareExpr on right, got %T", bc.Right)
}
qr2, ok := rightCmp.Left.(*QualifiedRef)
if !ok {
t.Fatalf("expected QualifiedRef, got %T", rightCmp.Left)
}
if qr2.Qualifier != "new" || qr2.Name != "status" {
t.Fatalf("expected new.status, got %s.%s", qr2.Qualifier, qr2.Name)
}
}
func TestParseTrigger_NoWhereGuard(t *testing.T) {
p := newTestParser()
input := `after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]`
trig, err := p.ParseTrigger(input)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if trig.Where != nil {
t.Fatal("expected nil Where guard")
}
if trig.Action == nil || trig.Action.Update == nil {
t.Fatal("expected Update action")
}
}

792
ruki/validate.go Normal file
View file

@ -0,0 +1,792 @@
package ruki
import "fmt"
// validate.go — structural validation and semantic type-checking.
// qualifierPolicy controls which old./new. qualifiers are allowed during validation.
type qualifierPolicy struct {
allowOld bool
allowNew bool
}
// no qualifiers allowed (standalone statements).
var noQualifiers = qualifierPolicy{}
func triggerQualifiers(event string) qualifierPolicy {
switch event {
case "create":
return qualifierPolicy{allowNew: true}
case "delete":
return qualifierPolicy{allowOld: true}
default: // "update"
return qualifierPolicy{allowOld: true, allowNew: true}
}
}
// known builtins and their return types.
var builtinFuncs = map[string]struct {
returnType ValueType
minArgs int
maxArgs int
}{
"count": {ValueInt, 1, 1},
"now": {ValueTimestamp, 0, 0},
"next_date": {ValueDate, 1, 1},
"blocks": {ValueListRef, 1, 1},
"contains": {ValueBool, 2, 2},
"call": {ValueString, 1, 1},
"user": {ValueString, 0, 0},
}
// --- structural validation ---
func (p *Parser) validateStatement(s *Statement) error {
switch {
case s.Create != nil:
if len(s.Create.Assignments) == 0 {
return fmt.Errorf("create must have at least one assignment")
}
return p.validateAssignments(s.Create.Assignments)
case s.Update != nil:
if len(s.Update.Set) == 0 {
return fmt.Errorf("update must have at least one assignment in set")
}
if err := p.validateCondition(s.Update.Where); err != nil {
return err
}
return p.validateAssignments(s.Update.Set)
case s.Delete != nil:
return p.validateCondition(s.Delete.Where)
case s.Select != nil:
if s.Select.Where != nil {
return p.validateCondition(s.Select.Where)
}
return nil
default:
return fmt.Errorf("empty statement")
}
}
func (p *Parser) validateTrigger(t *Trigger) error {
if t.Timing == "before" {
if t.Action != nil || t.Run != nil {
return fmt.Errorf("before-trigger must not have an action")
}
if t.Deny == nil {
return fmt.Errorf("before-trigger must have deny")
}
}
if t.Timing == "after" {
if t.Deny != nil {
return fmt.Errorf("after-trigger must not have deny")
}
if t.Action == nil && t.Run == nil {
return fmt.Errorf("after-trigger must have an action")
}
}
if t.Where != nil {
if err := p.validateCondition(t.Where); err != nil {
return err
}
}
if t.Action != nil {
if t.Action.Select != nil {
return fmt.Errorf("trigger action must not be select")
}
if err := p.validateStatement(t.Action); err != nil {
return err
}
}
if t.Run != nil {
typ, err := p.inferExprType(t.Run.Command)
if err != nil {
return fmt.Errorf("run command: %w", err)
}
if typ != ValueString {
return fmt.Errorf("run command must be string, got %s", typeName(typ))
}
}
return nil
}
func (p *Parser) validateAssignments(assignments []Assignment) error {
seen := make(map[string]struct{}, len(assignments))
for _, a := range assignments {
if _, dup := seen[a.Field]; dup {
return fmt.Errorf("duplicate assignment to field %q", a.Field)
}
seen[a.Field] = struct{}{}
fs, ok := p.schema.Field(a.Field)
if !ok {
return fmt.Errorf("unknown field %q in assignment", a.Field)
}
rhsType, err := p.inferExprType(a.Value)
if err != nil {
return fmt.Errorf("field %q: %w", a.Field, err)
}
if err := p.checkAssignmentCompat(fs.Type, rhsType, a.Value); err != nil {
return fmt.Errorf("field %q: %w", a.Field, err)
}
}
return nil
}
// --- condition validation with type-checking ---
func (p *Parser) validateCondition(c Condition) error {
switch c := c.(type) {
case *BinaryCondition:
if err := p.validateCondition(c.Left); err != nil {
return err
}
return p.validateCondition(c.Right)
case *NotCondition:
return p.validateCondition(c.Inner)
case *CompareExpr:
return p.validateCompare(c)
case *IsEmptyExpr:
_, err := p.inferExprType(c.Expr)
return err
case *InExpr:
return p.validateIn(c)
case *QuantifierExpr:
return p.validateQuantifier(c)
default:
return fmt.Errorf("unknown condition type %T", c)
}
}
func (p *Parser) validateCompare(c *CompareExpr) error {
leftType, err := p.inferExprType(c.Left)
if err != nil {
return err
}
rightType, err := p.inferExprType(c.Right)
if err != nil {
return err
}
// resolve empty from context
leftType, rightType = resolveEmptyPair(leftType, rightType)
if !typesCompatible(leftType, rightType) {
return fmt.Errorf("cannot compare %s %s %s", typeName(leftType), c.Op, typeName(rightType))
}
// reject cross-type comparisons involving enum fields,
// unless the other side is a string literal (e.g. status = "done")
if err := p.checkCompareCompat(leftType, rightType, c.Left, c.Right); err != nil {
return err
}
// use the most specific type for operator and enum validation
enumType := leftType
if rightType == ValueStatus || rightType == ValueTaskType {
enumType = rightType
}
if err := checkCompareOp(enumType, c.Op); err != nil {
return err
}
return p.validateEnumLiterals(c.Left, c.Right, enumType)
}
func (p *Parser) validateIn(c *InExpr) error {
valType, err := p.inferExprType(c.Value)
if err != nil {
return err
}
// infer collection type first — this validates list homogeneity
collType, err := p.inferExprType(c.Collection)
if err != nil {
return err
}
// get the actual element type, checking literal elements directly
elemType, err := p.inferListElementType(c.Collection)
if err != nil {
return err
}
if listElementType(collType) == -1 {
return fmt.Errorf("%s is not a collection type; use contains() for substring checks", typeName(collType))
}
if !membershipCompatible(valType, elemType) {
// allow string-like values in list literals whose elements are all string literals
ll, isLiteral := c.Collection.(*ListLiteral)
if !isLiteral || !isStringLike(valType) || !allStringLiterals(ll) {
return fmt.Errorf("element type mismatch: %s in %s", typeName(valType), typeName(collType))
}
}
return p.validateEnumListElements(c.Collection, valType)
}
func (p *Parser) validateQuantifier(q *QuantifierExpr) error {
exprType, err := p.inferExprType(q.Expr)
if err != nil {
return err
}
if exprType != ValueListRef {
return fmt.Errorf("quantifier %s requires list<ref>, got %s", q.Kind, typeName(exprType))
}
saved := p.qualifiers
p.qualifiers = noQualifiers
err = p.validateCondition(q.Condition)
p.qualifiers = saved
return err
}
// --- type inference ---
func (p *Parser) inferExprType(e Expr) (ValueType, error) {
switch e := e.(type) {
case *FieldRef:
fs, ok := p.schema.Field(e.Name)
if !ok {
return 0, fmt.Errorf("unknown field %q", e.Name)
}
return fs.Type, nil
case *QualifiedRef:
if e.Qualifier == "old" && !p.qualifiers.allowOld {
return 0, fmt.Errorf("old. qualifier is not valid in this context")
}
if e.Qualifier == "new" && !p.qualifiers.allowNew {
return 0, fmt.Errorf("new. qualifier is not valid in this context")
}
fs, ok := p.schema.Field(e.Name)
if !ok {
return 0, fmt.Errorf("unknown field %q in %s.%s", e.Name, e.Qualifier, e.Name)
}
return fs.Type, nil
case *StringLiteral:
return ValueString, nil
case *IntLiteral:
return ValueInt, nil
case *DateLiteral:
return ValueDate, nil
case *DurationLiteral:
return ValueDuration, nil
case *ListLiteral:
return p.inferListType(e)
case *EmptyLiteral:
return -1, nil // sentinel: resolved from context
case *FunctionCall:
return p.inferFuncCallType(e)
case *BinaryExpr:
return p.inferBinaryExprType(e)
case *SubQuery:
return 0, fmt.Errorf("subquery is only valid as argument to count()")
default:
return 0, fmt.Errorf("unknown expression type %T", e)
}
}
func (p *Parser) inferListType(l *ListLiteral) (ValueType, error) {
if len(l.Elements) == 0 {
return ValueListString, nil // default empty list type
}
firstType, err := p.inferExprType(l.Elements[0])
if err != nil {
return 0, err
}
for i := 1; i < len(l.Elements); i++ {
t, err := p.inferExprType(l.Elements[i])
if err != nil {
return 0, err
}
if !typesCompatible(firstType, t) {
return 0, fmt.Errorf("list elements must be the same type: got %s and %s", typeName(firstType), typeName(t))
}
}
switch firstType {
case ValueRef, ValueID:
return ValueListRef, nil
default:
return ValueListString, nil
}
}
// inferListElementType returns the element type of a list expression,
// checking literal elements directly when the list type enum is too coarse.
func (p *Parser) inferListElementType(e Expr) (ValueType, error) {
if ll, ok := e.(*ListLiteral); ok && len(ll.Elements) > 0 {
return p.inferExprType(ll.Elements[0])
}
collType, err := p.inferExprType(e)
if err != nil {
return 0, err
}
elem := listElementType(collType)
if elem == -1 {
return collType, nil // not a list type — return as-is for error reporting
}
return elem, nil
}
func (p *Parser) inferFuncCallType(fc *FunctionCall) (ValueType, error) {
builtin, ok := builtinFuncs[fc.Name]
if !ok {
return 0, fmt.Errorf("unknown function %q", fc.Name)
}
if len(fc.Args) < builtin.minArgs || len(fc.Args) > builtin.maxArgs {
if builtin.minArgs == builtin.maxArgs {
return 0, fmt.Errorf("%s() expects %d argument(s), got %d", fc.Name, builtin.minArgs, len(fc.Args))
}
return 0, fmt.Errorf("%s() expects %d-%d arguments, got %d", fc.Name, builtin.minArgs, builtin.maxArgs, len(fc.Args))
}
// validate argument types for specific functions
switch fc.Name {
case "count":
sq, ok := fc.Args[0].(*SubQuery)
if !ok {
return 0, fmt.Errorf("count() argument must be a select subquery")
}
if sq.Where != nil {
if err := p.validateCondition(sq.Where); err != nil {
return 0, fmt.Errorf("count() subquery: %w", err)
}
}
case "blocks":
argType, err := p.inferExprType(fc.Args[0])
if err != nil {
return 0, err
}
if argType != ValueID && argType != ValueRef && argType != ValueString {
return 0, fmt.Errorf("blocks() argument must be an id or ref, got %s", typeName(argType))
}
if argType == ValueString {
if _, ok := fc.Args[0].(*StringLiteral); !ok {
return 0, fmt.Errorf("blocks() argument must be an id or ref, got %s", typeName(argType))
}
}
case "contains":
for i, arg := range fc.Args {
t, err := p.inferExprType(arg)
if err != nil {
return 0, err
}
if t != ValueString {
return 0, fmt.Errorf("contains() argument %d must be string, got %s", i+1, typeName(t))
}
}
case "call":
t, err := p.inferExprType(fc.Args[0])
if err != nil {
return 0, err
}
if t != ValueString {
return 0, fmt.Errorf("call() argument must be string, got %s", typeName(t))
}
case "next_date":
t, err := p.inferExprType(fc.Args[0])
if err != nil {
return 0, err
}
if t != ValueRecurrence {
return 0, fmt.Errorf("next_date() argument must be recurrence, got %s", typeName(t))
}
}
return builtin.returnType, nil
}
func (p *Parser) inferBinaryExprType(b *BinaryExpr) (ValueType, error) {
leftType, err := p.inferExprType(b.Left)
if err != nil {
return 0, err
}
rightType, err := p.inferExprType(b.Right)
if err != nil {
return 0, err
}
leftType, rightType = resolveEmptyPair(leftType, rightType)
switch b.Op {
case "+":
return p.inferPlusType(leftType, rightType, b.Right)
case "-":
return p.inferMinusType(leftType, rightType, b.Right)
default:
return 0, fmt.Errorf("unknown binary operator %q", b.Op)
}
}
func isStringLike(t ValueType) bool {
switch t {
case ValueString, ValueStatus, ValueTaskType, ValueID, ValueRef:
return true
default:
return false
}
}
func (p *Parser) inferPlusType(left, right ValueType, rightExpr Expr) (ValueType, error) {
switch {
case isStringLike(left) && isStringLike(right):
return ValueString, nil
case left == ValueInt && right == ValueInt:
return ValueInt, nil
case left == ValueListString && (right == ValueString || right == ValueListString):
return ValueListString, nil
case left == ValueListRef && (isRefCompatible(right) || right == ValueListRef):
return ValueListRef, nil
case left == ValueListRef && right == ValueString:
if _, ok := rightExpr.(*StringLiteral); ok {
return ValueListRef, nil
}
return 0, fmt.Errorf("cannot add %s + %s", typeName(left), typeName(right))
case left == ValueListRef && right == ValueListString:
if _, ok := rightExpr.(*ListLiteral); ok {
return ValueListRef, nil
}
return 0, fmt.Errorf("cannot add list<string> field to list<ref>")
case left == ValueDate && right == ValueDuration:
return ValueDate, nil
case left == ValueTimestamp && right == ValueDuration:
return ValueTimestamp, nil
default:
return 0, fmt.Errorf("cannot add %s + %s", typeName(left), typeName(right))
}
}
func (p *Parser) inferMinusType(left, right ValueType, rightExpr Expr) (ValueType, error) {
switch {
case left == ValueListString && (right == ValueString || right == ValueListString):
return ValueListString, nil
case left == ValueListRef && (isRefCompatible(right) || right == ValueListRef):
return ValueListRef, nil
case left == ValueListRef && right == ValueString:
if _, ok := rightExpr.(*StringLiteral); ok {
return ValueListRef, nil
}
return 0, fmt.Errorf("cannot subtract %s - %s", typeName(left), typeName(right))
case left == ValueListRef && right == ValueListString:
if _, ok := rightExpr.(*ListLiteral); ok {
return ValueListRef, nil
}
return 0, fmt.Errorf("cannot subtract list<string> field from list<ref>")
case left == ValueInt && right == ValueInt:
return ValueInt, nil
case left == ValueDate && right == ValueDuration:
return ValueDate, nil
case left == ValueDate && right == ValueDate:
return ValueDuration, nil
case left == ValueTimestamp && right == ValueDuration:
return ValueTimestamp, nil
case left == ValueTimestamp && right == ValueTimestamp:
return ValueDuration, nil
default:
return 0, fmt.Errorf("cannot subtract %s - %s", typeName(left), typeName(right))
}
}
// --- enum literal validation ---
func (p *Parser) validateEnumLiterals(left, right Expr, resolvedType ValueType) error {
if resolvedType == ValueStatus {
if s, ok := right.(*StringLiteral); ok {
if _, valid := p.schema.NormalizeStatus(s.Value); !valid {
return fmt.Errorf("unknown status %q", s.Value)
}
}
if s, ok := left.(*StringLiteral); ok {
if _, valid := p.schema.NormalizeStatus(s.Value); !valid {
return fmt.Errorf("unknown status %q", s.Value)
}
}
}
if resolvedType == ValueTaskType {
if s, ok := right.(*StringLiteral); ok {
if _, valid := p.schema.NormalizeType(s.Value); !valid {
return fmt.Errorf("unknown type %q", s.Value)
}
}
if s, ok := left.(*StringLiteral); ok {
if _, valid := p.schema.NormalizeType(s.Value); !valid {
return fmt.Errorf("unknown type %q", s.Value)
}
}
}
return nil
}
// validateEnumListElements checks string literals inside a list expression
// against the appropriate enum normalizer, based on the value type being checked.
func (p *Parser) validateEnumListElements(collection Expr, valType ValueType) error {
ll, ok := collection.(*ListLiteral)
if !ok {
return nil
}
for _, elem := range ll.Elements {
s, ok := elem.(*StringLiteral)
if !ok {
continue
}
if valType == ValueStatus {
if _, valid := p.schema.NormalizeStatus(s.Value); !valid {
return fmt.Errorf("unknown status %q", s.Value)
}
}
if valType == ValueTaskType {
if _, valid := p.schema.NormalizeType(s.Value); !valid {
return fmt.Errorf("unknown type %q", s.Value)
}
}
}
return nil
}
// --- assignment compatibility ---
func (p *Parser) checkAssignmentCompat(fieldType, rhsType ValueType, rhs Expr) error {
// empty is assignable to anything
if _, ok := rhs.(*EmptyLiteral); ok {
return nil
}
if rhsType == -1 { // unresolved empty
return nil
}
if typesCompatible(fieldType, rhsType) {
// enum fields only accept same-type or string literals
if (fieldType == ValueStatus || fieldType == ValueTaskType) && rhsType != fieldType {
if _, ok := rhs.(*StringLiteral); !ok {
return fmt.Errorf("cannot assign %s to %s field", typeName(rhsType), typeName(fieldType))
}
}
// non-enum string-like fields reject enum-typed RHS
if (fieldType == ValueString || fieldType == ValueID || fieldType == ValueRef) &&
(rhsType == ValueStatus || rhsType == ValueTaskType) {
return fmt.Errorf("cannot assign %s to %s field", typeName(rhsType), typeName(fieldType))
}
// list<string> field rejects list literals with non-string elements
if fieldType == ValueListString {
if ll, ok := rhs.(*ListLiteral); ok {
for _, elem := range ll.Elements {
elemType, err := p.inferExprType(elem)
if err == nil && elemType != ValueString {
if _, isLit := elem.(*StringLiteral); !isLit {
return fmt.Errorf("cannot assign %s to list<string> field", typeName(elemType))
}
}
}
}
}
// validate enum values
if fieldType == ValueStatus {
if s, ok := rhs.(*StringLiteral); ok {
if _, valid := p.schema.NormalizeStatus(s.Value); !valid {
return fmt.Errorf("unknown status %q", s.Value)
}
}
}
if fieldType == ValueTaskType {
if s, ok := rhs.(*StringLiteral); ok {
if _, valid := p.schema.NormalizeType(s.Value); !valid {
return fmt.Errorf("unknown type %q", s.Value)
}
}
}
return nil
}
// list<string> literal is assignable to list<ref>, but only if all elements are string literals
if fieldType == ValueListRef && rhsType == ValueListString {
if ll, ok := rhs.(*ListLiteral); ok && allStringLiterals(ll) {
return nil
}
}
return fmt.Errorf("cannot assign %s to %s field", typeName(rhsType), typeName(fieldType))
}
// --- type helpers ---
func typesCompatible(a, b ValueType) bool {
if a == b {
return true
}
if a == -1 || b == -1 { // unresolved empty
return true
}
// string-like types are compatible with each other
stringLike := map[ValueType]bool{
ValueString: true,
ValueStatus: true,
ValueTaskType: true,
ValueID: true,
ValueRef: true,
}
return stringLike[a] && stringLike[b]
}
func isEnumType(t ValueType) bool {
return t == ValueStatus || t == ValueTaskType
}
// allStringLiterals returns true if every element in the list is a *StringLiteral.
func allStringLiterals(ll *ListLiteral) bool {
for _, elem := range ll.Elements {
if _, ok := elem.(*StringLiteral); !ok {
return false
}
}
return true
}
// checkCompareCompat rejects nonsensical cross-type comparisons in WHERE clauses.
// e.g. status = title (enum vs string field) is rejected,
// but status = "done" (enum vs string literal) is allowed.
func (p *Parser) checkCompareCompat(leftType, rightType ValueType, left, right Expr) error {
if isEnumType(leftType) && rightType != leftType {
if err := checkEnumOperand(leftType, rightType, right); err != nil {
return err
}
}
if isEnumType(rightType) && leftType != rightType {
if err := checkEnumOperand(rightType, leftType, left); err != nil {
return err
}
}
return nil
}
func checkEnumOperand(enumType, otherType ValueType, other Expr) error {
if otherType == ValueString {
if _, ok := other.(*StringLiteral); !ok {
return fmt.Errorf("cannot compare %s with %s field", typeName(enumType), typeName(otherType))
}
return nil
}
return fmt.Errorf("cannot compare %s with %s", typeName(enumType), typeName(otherType))
}
// membershipCompatible checks strict type compatibility for in/not in
// expressions. Unlike typesCompatible, it does not treat all string-like
// types as interchangeable — only ID and Ref are interchangeable.
func membershipCompatible(a, b ValueType) bool {
if a == b {
return true
}
if a == -1 || b == -1 {
return true
}
// ID and Ref are the same concept
if (a == ValueID || a == ValueRef) && (b == ValueID || b == ValueRef) {
return true
}
return false
}
// isRefCompatible returns true for types that can appear as operands
// in list<ref> add/remove operations.
func isRefCompatible(t ValueType) bool {
switch t {
case ValueRef, ValueID:
return true
default:
return false
}
}
func resolveEmptyPair(a, b ValueType) (ValueType, ValueType) {
if a == -1 && b != -1 {
a = b
}
if b == -1 && a != -1 {
b = a
}
return a, b
}
func listElementType(t ValueType) ValueType {
switch t {
case ValueListString:
return ValueString
case ValueListRef:
return ValueRef
default:
return -1
}
}
func checkCompareOp(t ValueType, op string) error {
switch op {
case "=", "!=":
return nil // all types support equality
case "<", ">", "<=", ">=":
switch t {
case ValueInt, ValueDate, ValueTimestamp, ValueDuration:
return nil
default:
return fmt.Errorf("operator %s not supported for %s", op, typeName(t))
}
default:
return fmt.Errorf("unknown operator %q", op)
}
}
func typeName(t ValueType) string {
switch t {
case ValueString:
return "string"
case ValueInt:
return "int"
case ValueDate:
return "date"
case ValueTimestamp:
return "timestamp"
case ValueDuration:
return "duration"
case ValueBool:
return "bool"
case ValueID:
return "id"
case ValueRef:
return "ref"
case ValueRecurrence:
return "recurrence"
case ValueListString:
return "list<string>"
case ValueListRef:
return "list<ref>"
case ValueStatus:
return "status"
case ValueTaskType:
return "type"
case -1:
return "empty"
default:
return "unknown"
}
}

1561
ruki/validate_test.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -64,3 +64,14 @@ func TypeEmoji(taskType Type) string {
func TypeDisplay(taskType Type) string {
return currentTypeRegistry().TypeDisplay(taskType)
}
// ParseDisplay reverses a TypeDisplay() string back to a canonical key.
// Returns (key, true) on match, or (fallback, false) for unrecognized display strings.
func ParseDisplay(display string) (Type, bool) {
return currentTypeRegistry().ParseDisplay(display)
}
// AllTypes returns the ordered list of all configured type keys.
func AllTypes() []Type {
return currentTypeRegistry().Keys()
}

View file

@ -33,11 +33,10 @@ func (ev *TaskEditView) ensureStatusSelectList(task *taskpkg.Task) *component.Ed
func (ev *TaskEditView) ensureTypeSelectList(task *taskpkg.Task) *component.EditSelectList {
if ev.typeSelectList == nil {
typeOptions := []string{
taskpkg.TypeDisplay(taskpkg.TypeStory),
taskpkg.TypeDisplay(taskpkg.TypeBug),
taskpkg.TypeDisplay(taskpkg.TypeSpike),
taskpkg.TypeDisplay(taskpkg.TypeEpic),
allTypes := taskpkg.AllTypes()
typeOptions := make([]string, len(allTypes))
for i, t := range allTypes {
typeOptions[i] = taskpkg.TypeDisplay(t)
}
colors := config.GetColors()

68
workflow/fields.go Normal file
View file

@ -0,0 +1,68 @@
package workflow
// ValueType identifies the semantic type of a task field.
type ValueType int
const (
TypeString ValueType = iota
TypeInt // numeric (priority, points)
TypeDate // midnight-UTC date (e.g. due)
TypeTimestamp // full timestamp (e.g. createdAt, updatedAt)
TypeDuration // reserved for future use
TypeBool // reserved for future use
TypeID // task identifier
TypeRef // reference to another task ID
TypeRecurrence // structured cron-based recurrence pattern
TypeListString // []string (e.g. tags)
TypeListRef // []string of task ID references (e.g. dependsOn)
TypeStatus // workflow status enum backed by StatusRegistry
TypeTaskType // task type enum backed by TypeRegistry
)
// FieldDef describes a single task field's name and semantic type.
type FieldDef struct {
Name string
Type ValueType
}
// fieldCatalog is the authoritative list of DSL-visible task fields.
var fieldCatalog = []FieldDef{
{Name: "id", Type: TypeID},
{Name: "title", Type: TypeString},
{Name: "description", Type: TypeString},
{Name: "status", Type: TypeStatus},
{Name: "type", Type: TypeTaskType},
{Name: "tags", Type: TypeListString},
{Name: "dependsOn", Type: TypeListRef},
{Name: "due", Type: TypeDate},
{Name: "recurrence", Type: TypeRecurrence},
{Name: "assignee", Type: TypeString},
{Name: "priority", Type: TypeInt},
{Name: "points", Type: TypeInt},
{Name: "createdBy", Type: TypeString},
{Name: "createdAt", Type: TypeTimestamp},
{Name: "updatedAt", Type: TypeTimestamp},
}
// pre-built lookup for Field()
var fieldByName map[string]FieldDef
func init() {
fieldByName = make(map[string]FieldDef, len(fieldCatalog))
for _, f := range fieldCatalog {
fieldByName[f.Name] = f
}
}
// Field returns the FieldDef for a given field name and whether it exists.
func Field(name string) (FieldDef, bool) {
f, ok := fieldByName[name]
return f, ok
}
// Fields returns the ordered list of all DSL-visible task fields.
func Fields() []FieldDef {
result := make([]FieldDef, len(fieldCatalog))
copy(result, fieldCatalog)
return result
}

74
workflow/fields_test.go Normal file
View file

@ -0,0 +1,74 @@
package workflow
import "testing"
func TestField(t *testing.T) {
tests := []struct {
name string
want ValueType
wantOK bool
}{
{"id", TypeID, true},
{"title", TypeString, true},
{"description", TypeString, true},
{"status", TypeStatus, true},
{"type", TypeTaskType, true},
{"tags", TypeListString, true},
{"dependsOn", TypeListRef, true},
{"due", TypeDate, true},
{"recurrence", TypeRecurrence, true},
{"assignee", TypeString, true},
{"priority", TypeInt, true},
{"points", TypeInt, true},
{"createdBy", TypeString, true},
{"createdAt", TypeTimestamp, true},
{"updatedAt", TypeTimestamp, true},
{"nonexistent", 0, false},
{"comments", 0, false}, // excluded from DSL catalog
{"loadedMtime", 0, false}, // excluded from DSL catalog
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, ok := Field(tt.name)
if ok != tt.wantOK {
t.Errorf("Field(%q) ok = %v, want %v", tt.name, ok, tt.wantOK)
return
}
if ok && f.Type != tt.want {
t.Errorf("Field(%q).Type = %v, want %v", tt.name, f.Type, tt.want)
}
})
}
}
func TestFields(t *testing.T) {
fields := Fields()
if len(fields) != 15 {
t.Fatalf("expected 15 fields, got %d", len(fields))
}
// verify it returns a copy
fields[0].Name = "modified"
original, _ := Field("id")
if original.Name == "modified" {
t.Error("Fields() should return a copy, not a reference to the internal slice")
}
}
func TestDateVsTimestamp(t *testing.T) {
due, _ := Field("due")
if due.Type != TypeDate {
t.Errorf("due should be TypeDate, got %v", due.Type)
}
createdAt, _ := Field("createdAt")
if createdAt.Type != TypeTimestamp {
t.Errorf("createdAt should be TypeTimestamp, got %v", createdAt.Type)
}
updatedAt, _ := Field("updatedAt")
if updatedAt.Type != TypeTimestamp {
t.Errorf("updatedAt should be TypeTimestamp, got %v", updatedAt.Type)
}
}

156
workflow/status.go Normal file
View file

@ -0,0 +1,156 @@
package workflow
import (
"fmt"
"log/slog"
"strings"
)
// StatusKey is a named type for workflow status keys.
// All status keys are normalized: lowercase, underscores as separators.
type StatusKey string
// well-known status constants (defaults from workflow.yaml template)
const (
StatusBacklog StatusKey = "backlog"
StatusReady StatusKey = "ready"
StatusInProgress StatusKey = "in_progress"
StatusReview StatusKey = "review"
StatusDone StatusKey = "done"
)
// StatusDef defines a single workflow status.
type StatusDef struct {
Key string `yaml:"key"`
Label string `yaml:"label"`
Emoji string `yaml:"emoji"`
Active bool `yaml:"active"`
Default bool `yaml:"default"`
Done bool `yaml:"done"`
}
// StatusRegistry is an ordered collection of valid statuses.
// It is constructed from a list of StatusDef and provides lookup and query methods.
// StatusRegistry holds no global state — the populated singleton lives in config/.
type StatusRegistry struct {
statuses []StatusDef
byKey map[StatusKey]StatusDef
defaultKey StatusKey
doneKey StatusKey
}
// NormalizeStatusKey lowercases, trims, and replaces "-" and " " with "_".
// This preserves multi-word keys (e.g. "in-progress" → "in_progress").
func NormalizeStatusKey(key string) StatusKey {
normalized := strings.ToLower(strings.TrimSpace(key))
normalized = strings.ReplaceAll(normalized, "-", "_")
normalized = strings.ReplaceAll(normalized, " ", "_")
return StatusKey(normalized)
}
// NewStatusRegistry constructs a StatusRegistry from the given definitions.
// Returns an error if keys are empty, duplicated, or the list is empty.
func NewStatusRegistry(defs []StatusDef) (*StatusRegistry, error) {
if len(defs) == 0 {
return nil, fmt.Errorf("statuses list is empty")
}
reg := &StatusRegistry{
statuses: make([]StatusDef, 0, len(defs)),
byKey: make(map[StatusKey]StatusDef, len(defs)),
}
for i, def := range defs {
if def.Key == "" {
return nil, fmt.Errorf("status at index %d has empty key", i)
}
normalized := NormalizeStatusKey(def.Key)
def.Key = string(normalized)
if _, exists := reg.byKey[normalized]; exists {
return nil, fmt.Errorf("duplicate status key %q", normalized)
}
if def.Default {
if reg.defaultKey != "" {
slog.Warn("multiple statuses marked default; using first", "first", reg.defaultKey, "duplicate", normalized)
} else {
reg.defaultKey = normalized
}
}
if def.Done {
if reg.doneKey != "" {
slog.Warn("multiple statuses marked done; using first", "first", reg.doneKey, "duplicate", normalized)
} else {
reg.doneKey = normalized
}
}
reg.byKey[normalized] = def
reg.statuses = append(reg.statuses, def)
}
// if no explicit default, use the first status
if reg.defaultKey == "" {
reg.defaultKey = StatusKey(reg.statuses[0].Key)
slog.Warn("no status marked default; using first status", "key", reg.defaultKey)
}
if reg.doneKey == "" {
slog.Warn("no status marked done; task completion features may not work correctly")
}
return reg, nil
}
// All returns the ordered list of status definitions.
// returns a copy to prevent callers from mutating internal state.
func (r *StatusRegistry) All() []StatusDef {
result := make([]StatusDef, len(r.statuses))
copy(result, r.statuses)
return result
}
// Lookup returns the StatusDef for a given key (normalized) and whether it exists.
func (r *StatusRegistry) Lookup(key string) (StatusDef, bool) {
def, ok := r.byKey[NormalizeStatusKey(key)]
return def, ok
}
// IsValid reports whether key is a recognized status.
func (r *StatusRegistry) IsValid(key string) bool {
_, ok := r.byKey[NormalizeStatusKey(key)]
return ok
}
// IsActive reports whether the status has the active flag set.
func (r *StatusRegistry) IsActive(key string) bool {
def, ok := r.byKey[NormalizeStatusKey(key)]
return ok && def.Active
}
// IsDone reports whether the status has the done flag set.
func (r *StatusRegistry) IsDone(key string) bool {
def, ok := r.byKey[NormalizeStatusKey(key)]
return ok && def.Done
}
// DefaultKey returns the key of the status with default: true.
func (r *StatusRegistry) DefaultKey() StatusKey {
return r.defaultKey
}
// DoneKey returns the key of the status with done: true.
func (r *StatusRegistry) DoneKey() StatusKey {
return r.doneKey
}
// Keys returns all status keys in definition order.
func (r *StatusRegistry) Keys() []StatusKey {
keys := make([]StatusKey, len(r.statuses))
for i, s := range r.statuses {
keys[i] = StatusKey(s.Key)
}
return keys
}

263
workflow/status_test.go Normal file
View file

@ -0,0 +1,263 @@
package workflow
import "testing"
func defaultTestStatuses() []StatusDef {
return []StatusDef{
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
{Key: "in_progress", Label: "In Progress", Emoji: "⚙️", Active: true},
{Key: "review", Label: "Review", Emoji: "👀", Active: true},
{Key: "done", Label: "Done", Emoji: "✅", Done: true},
}
}
func mustBuildStatusRegistry(t *testing.T, defs []StatusDef) *StatusRegistry {
t.Helper()
reg, err := NewStatusRegistry(defs)
if err != nil {
t.Fatalf("NewStatusRegistry: %v", err)
}
return reg
}
func TestNormalizeStatusKey(t *testing.T) {
tests := []struct {
input string
want StatusKey
}{
{"backlog", "backlog"},
{"BACKLOG", "backlog"},
{"In-Progress", "in_progress"},
{"in progress", "in_progress"},
{" DONE ", "done"},
{"In_Review", "in_review"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
if got := NormalizeStatusKey(tt.input); got != tt.want {
t.Errorf("NormalizeStatusKey(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestNewStatusRegistry_DefaultStatuses(t *testing.T) {
reg := mustBuildStatusRegistry(t, defaultTestStatuses())
if len(reg.All()) != 5 {
t.Fatalf("expected 5 statuses, got %d", len(reg.All()))
}
if reg.DefaultKey() != "backlog" {
t.Errorf("expected default key 'backlog', got %q", reg.DefaultKey())
}
if reg.DoneKey() != "done" {
t.Errorf("expected done key 'done', got %q", reg.DoneKey())
}
}
func TestNewStatusRegistry_CustomStatuses(t *testing.T) {
custom := []StatusDef{
{Key: "new", Label: "New", Emoji: "🆕", Default: true},
{Key: "wip", Label: "Work In Progress", Emoji: "🔧", Active: true},
{Key: "closed", Label: "Closed", Emoji: "🔒", Done: true},
}
reg := mustBuildStatusRegistry(t, custom)
if len(reg.All()) != 3 {
t.Fatalf("expected 3 statuses, got %d", len(reg.All()))
}
if reg.DefaultKey() != "new" {
t.Errorf("expected default key 'new', got %q", reg.DefaultKey())
}
if reg.DoneKey() != "closed" {
t.Errorf("expected done key 'closed', got %q", reg.DoneKey())
}
}
func TestStatusRegistry_IsValid(t *testing.T) {
reg := mustBuildStatusRegistry(t, defaultTestStatuses())
tests := []struct {
key string
want bool
}{
{"backlog", true},
{"ready", true},
{"in_progress", true},
{"In-Progress", true},
{"review", true},
{"done", true},
{"unknown", false},
{"", false},
{"todo", false},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
if got := reg.IsValid(tt.key); got != tt.want {
t.Errorf("IsValid(%q) = %v, want %v", tt.key, got, tt.want)
}
})
}
}
func TestStatusRegistry_IsActive(t *testing.T) {
reg := mustBuildStatusRegistry(t, defaultTestStatuses())
tests := []struct {
key string
want bool
}{
{"backlog", false},
{"ready", true},
{"in_progress", true},
{"review", true},
{"done", false},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
if got := reg.IsActive(tt.key); got != tt.want {
t.Errorf("IsActive(%q) = %v, want %v", tt.key, got, tt.want)
}
})
}
}
func TestStatusRegistry_IsDone(t *testing.T) {
reg := mustBuildStatusRegistry(t, defaultTestStatuses())
if !reg.IsDone("done") {
t.Error("expected 'done' to be marked as done")
}
if reg.IsDone("backlog") {
t.Error("expected 'backlog' to not be marked as done")
}
}
func TestStatusRegistry_Lookup(t *testing.T) {
reg := mustBuildStatusRegistry(t, defaultTestStatuses())
def, ok := reg.Lookup("ready")
if !ok {
t.Fatal("expected to find 'ready'")
}
if def.Label != "Ready" {
t.Errorf("expected label 'Ready', got %q", def.Label)
}
if def.Emoji != "📋" {
t.Errorf("expected emoji '📋', got %q", def.Emoji)
}
_, ok = reg.Lookup("nonexistent")
if ok {
t.Error("expected Lookup to return false for nonexistent key")
}
}
func TestStatusRegistry_Keys(t *testing.T) {
reg := mustBuildStatusRegistry(t, defaultTestStatuses())
keys := reg.Keys()
expected := []StatusKey{"backlog", "ready", "in_progress", "review", "done"}
if len(keys) != len(expected) {
t.Fatalf("expected %d keys, got %d", len(expected), len(keys))
}
for i, key := range keys {
if key != expected[i] {
t.Errorf("keys[%d] = %q, want %q", i, key, expected[i])
}
}
}
func TestStatusRegistry_NormalizesKeys(t *testing.T) {
custom := []StatusDef{
{Key: "In-Progress", Label: "In Progress", Default: true},
{Key: " DONE ", Label: "Done", Done: true},
}
reg := mustBuildStatusRegistry(t, custom)
if !reg.IsValid("in_progress") {
t.Error("expected 'in_progress' to be valid after normalization")
}
if !reg.IsValid("done") {
t.Error("expected 'done' to be valid after normalization")
}
}
func TestNewStatusRegistry_EmptyKey(t *testing.T) {
defs := []StatusDef{
{Key: "", Label: "No Key"},
}
_, err := NewStatusRegistry(defs)
if err == nil {
t.Error("expected error for empty key")
}
}
func TestNewStatusRegistry_DuplicateKey(t *testing.T) {
defs := []StatusDef{
{Key: "ready", Label: "Ready", Default: true},
{Key: "ready", Label: "Ready 2"},
}
_, err := NewStatusRegistry(defs)
if err == nil {
t.Error("expected error for duplicate key")
}
}
func TestNewStatusRegistry_Empty(t *testing.T) {
_, err := NewStatusRegistry(nil)
if err == nil {
t.Error("expected error for empty statuses")
}
}
func TestNewStatusRegistry_DefaultFallsToFirst(t *testing.T) {
defs := []StatusDef{
{Key: "alpha", Label: "Alpha"},
{Key: "beta", Label: "Beta"},
}
reg, err := NewStatusRegistry(defs)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if reg.DefaultKey() != "alpha" {
t.Errorf("expected default to fall back to first status 'alpha', got %q", reg.DefaultKey())
}
}
func TestStatusRegistry_AllReturnsCopy(t *testing.T) {
reg := mustBuildStatusRegistry(t, defaultTestStatuses())
all := reg.All()
all[0].Key = "mutated"
// internal state must be unchanged
keys := reg.Keys()
if keys[0] != "backlog" {
t.Errorf("All() mutation leaked into registry: first key = %q, want %q", keys[0], "backlog")
}
}
func TestStatusKeyConstants(t *testing.T) {
if StatusBacklog != "backlog" {
t.Errorf("StatusBacklog = %q", StatusBacklog)
}
if StatusReady != "ready" {
t.Errorf("StatusReady = %q", StatusReady)
}
if StatusInProgress != "in_progress" {
t.Errorf("StatusInProgress = %q", StatusInProgress)
}
if StatusReview != "review" {
t.Errorf("StatusReview = %q", StatusReview)
}
if StatusDone != "done" {
t.Errorf("StatusDone = %q", StatusDone)
}
}

195
workflow/tasktype.go Normal file
View file

@ -0,0 +1,195 @@
package workflow
import (
"fmt"
"strings"
)
// TaskType is a named type for workflow task type keys.
type TaskType string
// well-known built-in type constants.
const (
TypeStory TaskType = "story"
TypeBug TaskType = "bug"
TypeSpike TaskType = "spike"
TypeEpic TaskType = "epic"
)
// TypeDef defines a single task type with metadata and aliases.
type TypeDef struct {
Key TaskType
Label string
Emoji string
Aliases []string // e.g. "feature" and "task" → story
}
// DefaultTypeDefs returns the built-in type definitions.
func DefaultTypeDefs() []TypeDef {
return []TypeDef{
{Key: TypeStory, Label: "Story", Emoji: "🌀", Aliases: []string{"feature", "task"}},
{Key: TypeBug, Label: "Bug", Emoji: "💥"},
{Key: TypeSpike, Label: "Spike", Emoji: "🔍"},
{Key: TypeEpic, Label: "Epic", Emoji: "🗂️"},
}
}
// TypeRegistry is an ordered collection of valid task types.
// It is constructed from a list of TypeDef and provides lookup and normalization.
type TypeRegistry struct {
types []TypeDef
byKey map[TaskType]TypeDef
byAlias map[string]TaskType // normalized alias → canonical key
fallback TaskType // returned for unknown types
}
// NormalizeTypeKey lowercases, trims, and strips all separators ("-", "_", " ").
// Built-in type keys are single words, so stripping is lossless.
func NormalizeTypeKey(s string) TaskType {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "_", "")
s = strings.ReplaceAll(s, "-", "")
s = strings.ReplaceAll(s, " ", "")
return TaskType(s)
}
// NewTypeRegistry constructs a TypeRegistry from the given definitions.
// The first definition's key is used as the fallback for unknown types.
func NewTypeRegistry(defs []TypeDef) (*TypeRegistry, error) {
if len(defs) == 0 {
return nil, fmt.Errorf("type definitions list is empty")
}
reg := &TypeRegistry{
types: make([]TypeDef, 0, len(defs)),
byKey: make(map[TaskType]TypeDef, len(defs)),
byAlias: make(map[string]TaskType),
fallback: NormalizeTypeKey(string(defs[0].Key)),
}
// first pass: register all primary keys
for i, def := range defs {
if def.Key == "" {
return nil, fmt.Errorf("type at index %d has empty key", i)
}
normalized := NormalizeTypeKey(string(def.Key))
def.Key = normalized
defs[i] = def
if _, exists := reg.byKey[normalized]; exists {
return nil, fmt.Errorf("duplicate type key %q", normalized)
}
reg.byKey[normalized] = def
reg.types = append(reg.types, def)
}
// second pass: register aliases against the complete key set
for _, def := range defs {
for _, alias := range def.Aliases {
normAlias := string(NormalizeTypeKey(alias))
if existing, ok := reg.byAlias[normAlias]; ok {
return nil, fmt.Errorf("duplicate alias %q (already maps to %q)", alias, existing)
}
if _, ok := reg.byKey[TaskType(normAlias)]; ok {
return nil, fmt.Errorf("alias %q collides with primary key", alias)
}
reg.byAlias[normAlias] = def.Key
}
}
return reg, nil
}
// Lookup returns the TypeDef for a given key (normalized) and whether it exists.
func (r *TypeRegistry) Lookup(key TaskType) (TypeDef, bool) {
def, ok := r.byKey[NormalizeTypeKey(string(key))]
return def, ok
}
// ParseType parses a raw string into a TaskType with validation.
// Returns the canonical key and true if recognized (including aliases),
// or (fallback, false) for unknown types.
func (r *TypeRegistry) ParseType(s string) (TaskType, bool) {
normalized := NormalizeTypeKey(s)
// check primary keys
if _, ok := r.byKey[normalized]; ok {
return normalized, true
}
// check aliases
if canonical, ok := r.byAlias[string(normalized)]; ok {
return canonical, true
}
return r.fallback, false
}
// NormalizeType normalizes a raw string into a TaskType.
// Unknown types default to the fallback (first registered type).
func (r *TypeRegistry) NormalizeType(s string) TaskType {
t, _ := r.ParseType(s)
return t
}
// TypeLabel returns the human-readable label for a task type.
func (r *TypeRegistry) TypeLabel(t TaskType) string {
if def, ok := r.Lookup(t); ok {
return def.Label
}
return string(t)
}
// TypeEmoji returns the emoji for a task type.
func (r *TypeRegistry) TypeEmoji(t TaskType) string {
if def, ok := r.Lookup(t); ok {
return def.Emoji
}
return ""
}
// TypeDisplay returns "Label Emoji" for a task type.
func (r *TypeRegistry) TypeDisplay(t TaskType) string {
label := r.TypeLabel(t)
emoji := r.TypeEmoji(t)
if emoji == "" {
return label
}
return label + " " + emoji
}
// ParseDisplay reverses a TypeDisplay() string (e.g. "Bug 💥") back to
// its canonical key. Returns (key, true) on match, or (fallback, false).
func (r *TypeRegistry) ParseDisplay(display string) (TaskType, bool) {
for _, def := range r.types {
if r.TypeDisplay(def.Key) == display {
return def.Key, true
}
}
return r.fallback, false
}
// Keys returns all type keys in definition order.
func (r *TypeRegistry) Keys() []TaskType {
keys := make([]TaskType, len(r.types))
for i, td := range r.types {
keys[i] = td.Key
}
return keys
}
// All returns the ordered list of type definitions.
// returns a copy to prevent callers from mutating internal state.
func (r *TypeRegistry) All() []TypeDef {
result := make([]TypeDef, len(r.types))
copy(result, r.types)
return result
}
// IsValid reports whether key is a recognized type (primary key only, not alias).
func (r *TypeRegistry) IsValid(key TaskType) bool {
_, ok := r.byKey[NormalizeTypeKey(string(key))]
return ok
}

363
workflow/tasktype_test.go Normal file
View file

@ -0,0 +1,363 @@
package workflow
import "testing"
func mustBuildTypeRegistry(t *testing.T, defs []TypeDef) *TypeRegistry {
t.Helper()
reg, err := NewTypeRegistry(defs)
if err != nil {
t.Fatalf("NewTypeRegistry: %v", err)
}
return reg
}
func TestNormalizeTypeKey(t *testing.T) {
tests := []struct {
input string
want TaskType
}{
{"story", "story"},
{"Story", "story"},
{"BUG", "bug"},
{"SPIKE", "spike"},
{"in_progress", "inprogress"},
{"some-type", "sometype"},
{" EPIC ", "epic"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
if got := NormalizeTypeKey(tt.input); got != tt.want {
t.Errorf("NormalizeTypeKey(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestTypeRegistry_ParseType(t *testing.T) {
reg := mustBuildTypeRegistry(t, DefaultTypeDefs())
tests := []struct {
name string
input string
want TaskType
wantOK bool
}{
{"story", "story", TypeStory, true},
{"bug", "bug", TypeBug, true},
{"spike", "spike", TypeSpike, true},
{"epic", "epic", TypeEpic, true},
{"feature alias", "feature", TypeStory, true},
{"task alias", "task", TypeStory, true},
{"case insensitive", "Story", TypeStory, true},
{"uppercase", "BUG", TypeBug, true},
{"unknown", "unknown", TypeStory, false},
{"empty", "", TypeStory, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := reg.ParseType(tt.input)
if got != tt.want || ok != tt.wantOK {
t.Errorf("ParseType(%q) = (%q, %v), want (%q, %v)", tt.input, got, ok, tt.want, tt.wantOK)
}
})
}
}
func TestTypeRegistry_NormalizeType(t *testing.T) {
reg := mustBuildTypeRegistry(t, DefaultTypeDefs())
tests := []struct {
name string
input string
want TaskType
}{
{"story", "story", TypeStory},
{"bug", "bug", TypeBug},
{"spike", "spike", TypeSpike},
{"epic", "epic", TypeEpic},
{"feature alias", "feature", TypeStory},
{"task alias", "task", TypeStory},
{"case insensitive", "EPIC", TypeEpic},
{"unknown defaults to story", "unknown", TypeStory},
{"empty defaults to story", "", TypeStory},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := reg.NormalizeType(tt.input); got != tt.want {
t.Errorf("NormalizeType(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestTypeRegistry_TypeLabel(t *testing.T) {
reg := mustBuildTypeRegistry(t, DefaultTypeDefs())
tests := []struct {
input TaskType
want string
}{
{TypeStory, "Story"},
{TypeBug, "Bug"},
{TypeSpike, "Spike"},
{TypeEpic, "Epic"},
{TaskType("unknown"), "unknown"},
}
for _, tt := range tests {
t.Run(string(tt.input), func(t *testing.T) {
if got := reg.TypeLabel(tt.input); got != tt.want {
t.Errorf("TypeLabel(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestTypeRegistry_TypeEmoji(t *testing.T) {
reg := mustBuildTypeRegistry(t, DefaultTypeDefs())
tests := []struct {
input TaskType
want string
}{
{TypeStory, "🌀"},
{TypeBug, "💥"},
{TypeSpike, "🔍"},
{TypeEpic, "🗂️"},
{TaskType("unknown"), ""},
}
for _, tt := range tests {
t.Run(string(tt.input), func(t *testing.T) {
if got := reg.TypeEmoji(tt.input); got != tt.want {
t.Errorf("TypeEmoji(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestTypeRegistry_TypeDisplay(t *testing.T) {
reg := mustBuildTypeRegistry(t, DefaultTypeDefs())
tests := []struct {
input TaskType
want string
}{
{TypeStory, "Story 🌀"},
{TypeBug, "Bug 💥"},
{TypeSpike, "Spike 🔍"},
{TypeEpic, "Epic 🗂️"},
{TaskType("unknown"), "unknown"},
}
for _, tt := range tests {
t.Run(string(tt.input), func(t *testing.T) {
if got := reg.TypeDisplay(tt.input); got != tt.want {
t.Errorf("TypeDisplay(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestTypeRegistry_ParseDisplay(t *testing.T) {
reg := mustBuildTypeRegistry(t, DefaultTypeDefs())
tests := []struct {
name string
input string
want TaskType
wantOK bool
}{
{"story display", "Story 🌀", TypeStory, true},
{"bug display", "Bug 💥", TypeBug, true},
{"spike display", "Spike 🔍", TypeSpike, true},
{"epic display", "Epic 🗂️", TypeEpic, true},
{"unknown display", "Unknown", TypeStory, false},
{"label only", "Bug", TypeStory, false},
{"empty", "", TypeStory, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := reg.ParseDisplay(tt.input)
if got != tt.want || ok != tt.wantOK {
t.Errorf("ParseDisplay(%q) = (%q, %v), want (%q, %v)", tt.input, got, ok, tt.want, tt.wantOK)
}
})
}
}
func TestTypeRegistry_Keys(t *testing.T) {
reg := mustBuildTypeRegistry(t, DefaultTypeDefs())
keys := reg.Keys()
expected := []TaskType{TypeStory, TypeBug, TypeSpike, TypeEpic}
if len(keys) != len(expected) {
t.Fatalf("expected %d keys, got %d", len(expected), len(keys))
}
for i, key := range keys {
if key != expected[i] {
t.Errorf("keys[%d] = %q, want %q", i, key, expected[i])
}
}
}
func TestTypeRegistry_IsValid(t *testing.T) {
reg := mustBuildTypeRegistry(t, DefaultTypeDefs())
if !reg.IsValid(TypeStory) {
t.Error("expected story to be valid")
}
if reg.IsValid("feature") {
t.Error("expected alias 'feature' to not be valid as primary key")
}
if reg.IsValid("unknown") {
t.Error("expected unknown to not be valid")
}
}
func TestTypeRegistry_LookupNormalizesInput(t *testing.T) {
reg := mustBuildTypeRegistry(t, DefaultTypeDefs())
// Lookup should normalize inputs just like StatusRegistry does
tests := []struct {
name string
input TaskType
want bool
}{
{"lowercase", "story", true},
{"uppercase", "STORY", true},
{"mixed case", "Bug", true},
{"unknown", "nope", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, ok := reg.Lookup(tt.input)
if ok != tt.want {
t.Errorf("Lookup(%q) ok = %v, want %v", tt.input, ok, tt.want)
}
})
}
// TypeLabel/TypeEmoji/IsValid should also normalize
if label := reg.TypeLabel("BUG"); label != "Bug" {
t.Errorf("TypeLabel(BUG) = %q, want %q", label, "Bug")
}
if emoji := reg.TypeEmoji("EPIC"); emoji != "🗂️" {
t.Errorf("TypeEmoji(EPIC) = %q, want %q", emoji, "🗂️")
}
if !reg.IsValid("SPIKE") {
t.Error("expected IsValid(SPIKE) to be true after normalization")
}
}
func TestNewTypeRegistry_EmptyKey(t *testing.T) {
defs := []TypeDef{{Key: "", Label: "No Key"}}
_, err := NewTypeRegistry(defs)
if err == nil {
t.Error("expected error for empty key")
}
}
func TestNewTypeRegistry_DuplicateKey(t *testing.T) {
defs := []TypeDef{
{Key: "story", Label: "Story"},
{Key: "story", Label: "Story 2"},
}
_, err := NewTypeRegistry(defs)
if err == nil {
t.Error("expected error for duplicate key")
}
}
func TestNewTypeRegistry_DuplicateAlias(t *testing.T) {
defs := []TypeDef{
{Key: "story", Label: "Story", Aliases: []string{"feature"}},
{Key: "bug", Label: "Bug", Aliases: []string{"feature"}},
}
_, err := NewTypeRegistry(defs)
if err == nil {
t.Error("expected error for duplicate alias")
}
}
func TestNewTypeRegistry_AliasCollidesWithKey(t *testing.T) {
defs := []TypeDef{
{Key: "story", Label: "Story"},
{Key: "bug", Label: "Bug", Aliases: []string{"story"}},
}
_, err := NewTypeRegistry(defs)
if err == nil {
t.Error("expected error when alias collides with primary key")
}
}
func TestNewTypeRegistry_AliasCollidesWithLaterKey(t *testing.T) {
// alias "feature" on story should collide with later primary key "feature"
defs := []TypeDef{
{Key: "story", Label: "Story", Aliases: []string{"feature"}},
{Key: "feature", Label: "Feature"},
}
_, err := NewTypeRegistry(defs)
if err == nil {
t.Error("expected error when alias collides with a later primary key")
}
}
func TestNewTypeRegistry_FallbackNormalized(t *testing.T) {
defs := []TypeDef{
{Key: "Story", Label: "Story"},
{Key: "bug", Label: "Bug"},
}
reg := mustBuildTypeRegistry(t, defs)
// fallback should be normalized even though the input key was "Story"
got, ok := reg.ParseType("unknown-thing")
if ok {
t.Fatal("expected ok=false for unknown type")
}
if got != "story" {
t.Errorf("ParseType(unknown) = %q, want %q (normalized fallback)", got, "story")
}
}
func TestNewTypeRegistry_Empty(t *testing.T) {
_, err := NewTypeRegistry(nil)
if err == nil {
t.Error("expected error for empty type definitions")
}
}
func TestTypeRegistry_AllReturnsCopy(t *testing.T) {
reg := mustBuildTypeRegistry(t, DefaultTypeDefs())
all := reg.All()
all[0].Key = "mutated"
// internal state must be unchanged
keys := reg.Keys()
if keys[0] != TypeStory {
t.Errorf("All() mutation leaked into registry: first key = %q, want %q", keys[0], TypeStory)
}
}
func TestTypeConstants(t *testing.T) {
if TypeStory != "story" {
t.Errorf("TypeStory = %q", TypeStory)
}
if TypeBug != "bug" {
t.Errorf("TypeBug = %q", TypeBug)
}
if TypeSpike != "spike" {
t.Errorf("TypeSpike = %q", TypeSpike)
}
if TypeEpic != "epic" {
t.Errorf("TypeEpic = %q", TypeEpic)
}
}