diff --git a/ruki/executor.go b/ruki/executor.go new file mode 100644 index 0000000..9974219 --- /dev/null +++ b/ruki/executor.go @@ -0,0 +1,884 @@ +package ruki + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/boolean-maybe/tiki/task" +) + +// Executor evaluates parsed ruki statements against a set of tasks. +type Executor struct { + schema Schema + userFunc func() string +} + +// NewExecutor constructs an Executor with the given schema and user function. +// If userFunc is nil, calling user() at runtime will return "". +func NewExecutor(schema Schema, userFunc func() string) *Executor { + if userFunc == nil { + userFunc = func() string { return "" } + } + return &Executor{schema: schema, userFunc: userFunc} +} + +// Result holds the output of executing a statement. +// Exactly one variant is non-nil. +type Result struct { + Select *TaskProjection +} + +// TaskProjection holds the filtered, sorted tasks and the requested field list. +type TaskProjection struct { + Tasks []*task.Task + Fields []string // user-requested fields; nil/empty = all fields +} + +// Execute dispatches on the statement type and returns results. +func (e *Executor) Execute(stmt *Statement, tasks []*task.Task) (*Result, error) { + if stmt == nil { + return nil, fmt.Errorf("nil statement") + } + switch { + case stmt.Select != nil: + return e.executeSelect(stmt.Select, tasks) + case stmt.Create != nil: + return nil, fmt.Errorf("create is not supported yet") + case stmt.Update != nil: + return nil, fmt.Errorf("update is not supported yet") + case stmt.Delete != nil: + return nil, fmt.Errorf("delete is not supported yet") + default: + return nil, fmt.Errorf("empty statement") + } +} + +func (e *Executor) executeSelect(sel *SelectStmt, tasks []*task.Task) (*Result, error) { + filtered, err := e.filterTasks(sel.Where, tasks) + if err != nil { + return nil, err + } + + if len(sel.OrderBy) > 0 { + e.sortTasks(filtered, sel.OrderBy) + } + + return &Result{ + Select: &TaskProjection{ + Tasks: filtered, + Fields: sel.Fields, + }, + }, nil +} + +// --- filtering --- + +func (e *Executor) filterTasks(where Condition, tasks []*task.Task) ([]*task.Task, error) { + if where == nil { + result := make([]*task.Task, len(tasks)) + copy(result, tasks) + return result, nil + } + var result []*task.Task + for _, t := range tasks { + match, err := e.evalCondition(where, t, tasks) + if err != nil { + return nil, err + } + if match { + result = append(result, t) + } + } + return result, nil +} + +// --- condition evaluation --- + +func (e *Executor) evalCondition(c Condition, t *task.Task, allTasks []*task.Task) (bool, error) { + switch c := c.(type) { + case *BinaryCondition: + return e.evalBinaryCondition(c, t, allTasks) + case *NotCondition: + val, err := e.evalCondition(c.Inner, t, allTasks) + if err != nil { + return false, err + } + return !val, nil + case *CompareExpr: + return e.evalCompare(c, t, allTasks) + case *IsEmptyExpr: + return e.evalIsEmpty(c, t, allTasks) + case *InExpr: + return e.evalIn(c, t, allTasks) + case *QuantifierExpr: + return e.evalQuantifier(c, t, allTasks) + default: + return false, fmt.Errorf("unknown condition type %T", c) + } +} + +func (e *Executor) evalBinaryCondition(c *BinaryCondition, t *task.Task, allTasks []*task.Task) (bool, error) { + left, err := e.evalCondition(c.Left, t, allTasks) + if err != nil { + return false, err + } + switch c.Op { + case "and": + if !left { + return false, nil + } + return e.evalCondition(c.Right, t, allTasks) + case "or": + if left { + return true, nil + } + return e.evalCondition(c.Right, t, allTasks) + default: + return false, fmt.Errorf("unknown binary operator %q", c.Op) + } +} + +func (e *Executor) evalCompare(c *CompareExpr, t *task.Task, allTasks []*task.Task) (bool, error) { + leftVal, err := e.evalExpr(c.Left, t, allTasks) + if err != nil { + return false, err + } + rightVal, err := e.evalExpr(c.Right, t, allTasks) + if err != nil { + return false, err + } + return e.compareValues(leftVal, rightVal, c.Op, c.Left, c.Right) +} + +func (e *Executor) evalIsEmpty(c *IsEmptyExpr, t *task.Task, allTasks []*task.Task) (bool, error) { + val, err := e.evalExpr(c.Expr, t, allTasks) + if err != nil { + return false, err + } + empty := isZeroValue(val) + if c.Negated { + return !empty, nil + } + return empty, nil +} + +func (e *Executor) evalIn(c *InExpr, t *task.Task, allTasks []*task.Task) (bool, error) { + val, err := e.evalExpr(c.Value, t, allTasks) + if err != nil { + return false, err + } + collVal, err := e.evalExpr(c.Collection, t, allTasks) + if err != nil { + return false, err + } + list, ok := collVal.([]interface{}) + if !ok { + return false, fmt.Errorf("in: collection is not a list") + } + + valStr := normalizeToString(val) + found := false + for _, elem := range list { + if normalizeToString(elem) == valStr { + found = true + break + } + } + if c.Negated { + return !found, nil + } + return found, nil +} + +func (e *Executor) evalQuantifier(q *QuantifierExpr, t *task.Task, allTasks []*task.Task) (bool, error) { + listVal, err := e.evalExpr(q.Expr, t, allTasks) + if err != nil { + return false, err + } + refs, ok := listVal.([]interface{}) + if !ok { + return false, fmt.Errorf("quantifier: expression is not a list") + } + + // find referenced tasks + refTasks := make([]*task.Task, 0, len(refs)) + for _, ref := range refs { + refID := normalizeToString(ref) + for _, at := range allTasks { + if strings.EqualFold(at.ID, refID) { + refTasks = append(refTasks, at) + break + } + } + } + + switch q.Kind { + case "any": + for _, rt := range refTasks { + match, err := e.evalCondition(q.Condition, rt, allTasks) + if err != nil { + return false, err + } + if match { + return true, nil + } + } + return false, nil + case "all": + if len(refTasks) == 0 { + return true, nil // vacuous truth + } + for _, rt := range refTasks { + match, err := e.evalCondition(q.Condition, rt, allTasks) + if err != nil { + return false, err + } + if !match { + return false, nil + } + } + return true, nil + default: + return false, fmt.Errorf("unknown quantifier %q", q.Kind) + } +} + +// --- expression evaluation --- + +func (e *Executor) evalExpr(expr Expr, t *task.Task, allTasks []*task.Task) (interface{}, error) { + switch expr := expr.(type) { + case *FieldRef: + return extractField(t, expr.Name), nil + case *QualifiedRef: + return nil, fmt.Errorf("qualified references (old./new.) are not supported in standalone SELECT") + case *StringLiteral: + return expr.Value, nil + case *IntLiteral: + return expr.Value, nil + case *DateLiteral: + return expr.Value, nil + case *DurationLiteral: + return durationToTimeDelta(expr.Value, expr.Unit), nil + case *ListLiteral: + return e.evalListLiteral(expr, t, allTasks) + case *EmptyLiteral: + return nil, nil + case *FunctionCall: + return e.evalFunctionCall(expr, t, allTasks) + case *BinaryExpr: + return e.evalBinaryExpr(expr, t, allTasks) + case *SubQuery: + return nil, fmt.Errorf("subquery is only valid as argument to count()") + default: + return nil, fmt.Errorf("unknown expression type %T", expr) + } +} + +func (e *Executor) evalListLiteral(ll *ListLiteral, t *task.Task, allTasks []*task.Task) (interface{}, error) { + result := make([]interface{}, len(ll.Elements)) + for i, elem := range ll.Elements { + val, err := e.evalExpr(elem, t, allTasks) + if err != nil { + return nil, err + } + result[i] = val + } + return result, nil +} + +// --- function evaluation --- + +func (e *Executor) evalFunctionCall(fc *FunctionCall, t *task.Task, allTasks []*task.Task) (interface{}, error) { + switch fc.Name { + case "now": + return time.Now(), nil + case "user": + return e.userFunc(), nil + case "contains": + return e.evalContains(fc, t, allTasks) + case "count": + return e.evalCount(fc, allTasks) + case "next_date": + return e.evalNextDate(fc, t, allTasks) + case "blocks": + return e.evalBlocks(fc, t, allTasks) + case "call": + return nil, fmt.Errorf("call() is not supported yet") + default: + return nil, fmt.Errorf("unknown function %q", fc.Name) + } +} + +func (e *Executor) evalContains(fc *FunctionCall, t *task.Task, allTasks []*task.Task) (interface{}, error) { + haystack, err := e.evalExpr(fc.Args[0], t, allTasks) + if err != nil { + return nil, err + } + needle, err := e.evalExpr(fc.Args[1], t, allTasks) + if err != nil { + return nil, err + } + return strings.Contains(normalizeToString(haystack), normalizeToString(needle)), nil +} + +func (e *Executor) evalCount(fc *FunctionCall, allTasks []*task.Task) (interface{}, error) { + sq, ok := fc.Args[0].(*SubQuery) + if !ok { + return nil, fmt.Errorf("count() argument must be a select subquery") + } + if sq.Where == nil { + return len(allTasks), nil + } + count := 0 + for _, t := range allTasks { + match, err := e.evalCondition(sq.Where, t, allTasks) + if err != nil { + return nil, err + } + if match { + count++ + } + } + return count, nil +} + +func (e *Executor) evalNextDate(fc *FunctionCall, t *task.Task, allTasks []*task.Task) (interface{}, error) { + val, err := e.evalExpr(fc.Args[0], t, allTasks) + if err != nil { + return nil, err + } + rec, ok := val.(task.Recurrence) + if !ok { + return nil, fmt.Errorf("next_date() argument must be a recurrence value") + } + return task.NextOccurrence(rec), nil +} + +func (e *Executor) evalBlocks(fc *FunctionCall, t *task.Task, allTasks []*task.Task) (interface{}, error) { + val, err := e.evalExpr(fc.Args[0], t, allTasks) + if err != nil { + return nil, err + } + targetID := strings.ToUpper(normalizeToString(val)) + + var blockers []interface{} + for _, at := range allTasks { + for _, dep := range at.DependsOn { + if strings.EqualFold(dep, targetID) { + blockers = append(blockers, at.ID) + break + } + } + } + if blockers == nil { + blockers = []interface{}{} + } + return blockers, nil +} + +// --- binary expression evaluation --- + +func (e *Executor) evalBinaryExpr(b *BinaryExpr, t *task.Task, allTasks []*task.Task) (interface{}, error) { + leftVal, err := e.evalExpr(b.Left, t, allTasks) + if err != nil { + return nil, err + } + rightVal, err := e.evalExpr(b.Right, t, allTasks) + if err != nil { + return nil, err + } + + switch b.Op { + case "+": + return addValues(leftVal, rightVal) + case "-": + return subtractValues(leftVal, rightVal) + default: + return nil, fmt.Errorf("unknown binary operator %q", b.Op) + } +} + +func addValues(left, right interface{}) (interface{}, error) { + switch l := left.(type) { + case int: + if r, ok := right.(int); ok { + return l + r, nil + } + case time.Time: + if r, ok := right.(time.Duration); ok { + return l.Add(r), nil + } + case string: + if r, ok := right.(string); ok { + return l + r, nil + } + } + return nil, fmt.Errorf("cannot add %T + %T", left, right) +} + +func subtractValues(left, right interface{}) (interface{}, error) { + switch l := left.(type) { + case int: + if r, ok := right.(int); ok { + return l - r, nil + } + case time.Time: + switch r := right.(type) { + case time.Duration: + return l.Add(-r), nil + case time.Time: + return l.Sub(r), nil + } + } + return nil, fmt.Errorf("cannot subtract %T - %T", left, right) +} + +// --- sorting --- + +func (e *Executor) sortTasks(tasks []*task.Task, clauses []OrderByClause) { + sort.SliceStable(tasks, func(i, j int) bool { + for _, c := range clauses { + vi := extractField(tasks[i], c.Field) + vj := extractField(tasks[j], c.Field) + cmp := compareForSort(vi, vj) + if cmp == 0 { + continue + } + if c.Desc { + return cmp > 0 + } + return cmp < 0 + } + return false + }) +} + +func compareForSort(a, b interface{}) int { + if a == nil && b == nil { + return 0 + } + if a == nil { + return -1 + } + if b == nil { + return 1 + } + + switch av := a.(type) { + case int: + bv, _ := b.(int) + return compareInts(av, bv) + case string: + bv, _ := b.(string) + return strings.Compare(av, bv) + case task.Status: + bv, _ := b.(task.Status) + return strings.Compare(string(av), string(bv)) + case task.Type: + bv, _ := b.(task.Type) + return strings.Compare(string(av), string(bv)) + case time.Time: + bv, _ := b.(time.Time) + if av.Before(bv) { + return -1 + } + if av.After(bv) { + return 1 + } + return 0 + case task.Recurrence: + bv, _ := b.(task.Recurrence) + return strings.Compare(string(av), string(bv)) + case time.Duration: + bv, _ := b.(time.Duration) + if av < bv { + return -1 + } + if av > bv { + return 1 + } + return 0 + default: + return strings.Compare(fmt.Sprint(a), fmt.Sprint(b)) + } +} + +func compareInts(a, b int) int { + if a < b { + return -1 + } + if a > b { + return 1 + } + return 0 +} + +// --- comparison --- + +func (e *Executor) compareValues(left, right interface{}, op string, leftExpr, rightExpr Expr) (bool, error) { + if left == nil || right == nil { + return compareWithNil(left, right, op) + } + + if leftList, ok := left.([]interface{}); ok { + if rightList, ok := right.([]interface{}); ok { + return compareListEquality(leftList, rightList, op) + } + } + + if lb, ok := left.(bool); ok { + if rb, ok := right.(bool); ok { + return compareBools(lb, rb, op) + } + } + + compType := e.resolveComparisonType(leftExpr, rightExpr) + + switch compType { + case ValueID: + return compareStringsCI(normalizeToString(left), normalizeToString(right), op) + case ValueStatus: + ls := e.normalizeStatusStr(normalizeToString(left)) + rs := e.normalizeStatusStr(normalizeToString(right)) + return compareStrings(ls, rs, op) + case ValueTaskType: + ls := e.normalizeTypeStr(normalizeToString(left)) + rs := e.normalizeTypeStr(normalizeToString(right)) + return compareStrings(ls, rs, op) + } + + switch lv := left.(type) { + case string: + return compareStrings(lv, normalizeToString(right), op) + case int: + rv, ok := toInt(right) + if !ok { + return false, fmt.Errorf("cannot compare int with %T", right) + } + return compareIntValues(lv, rv, op) + case time.Time: + rv, ok := right.(time.Time) + if !ok { + return false, fmt.Errorf("cannot compare time with %T", right) + } + return compareTimes(lv, rv, op) + case time.Duration: + rv, ok := right.(time.Duration) + if !ok { + return false, fmt.Errorf("cannot compare duration with %T", right) + } + return compareDurations(lv, rv, op) + case task.Status: + return compareStrings(string(lv), normalizeToString(right), op) + case task.Type: + return compareStrings(string(lv), normalizeToString(right), op) + case task.Recurrence: + return compareStrings(string(lv), normalizeToString(right), op) + default: + return false, fmt.Errorf("unsupported comparison type %T", left) + } +} + +// resolveComparisonType returns the dominant field type for a comparison, +// checking both sides for enum/id fields that need special handling. +func (e *Executor) resolveComparisonType(left, right Expr) ValueType { + if t := e.exprFieldType(left); t == ValueID || t == ValueStatus || t == ValueTaskType { + return t + } + if t := e.exprFieldType(right); t == ValueID || t == ValueStatus || t == ValueTaskType { + return t + } + return -1 +} + +func (e *Executor) exprFieldType(expr Expr) ValueType { + fr, ok := expr.(*FieldRef) + if !ok { + return -1 + } + fs, ok := e.schema.Field(fr.Name) + if !ok { + return -1 + } + return fs.Type +} + +func (e *Executor) normalizeStatusStr(s string) string { + if norm, ok := e.schema.NormalizeStatus(s); ok { + return norm + } + return s +} + +func (e *Executor) normalizeTypeStr(s string) string { + if norm, ok := e.schema.NormalizeType(s); ok { + return norm + } + return s +} + +func compareWithNil(left, right interface{}, op string) (bool, error) { + // treat nil as empty; treat zero-valued non-nil as also matching empty + leftEmpty := isZeroValue(left) + rightEmpty := isZeroValue(right) + bothEmpty := leftEmpty && rightEmpty + switch op { + case "=": + return bothEmpty, nil + case "!=": + return !bothEmpty, nil + default: + return false, nil + } +} + +func compareListEquality(a, b []interface{}, op string) (bool, error) { + switch op { + case "=": + return sortedMultisetEqual(a, b), nil + case "!=": + return !sortedMultisetEqual(a, b), nil + default: + return false, fmt.Errorf("operator %s not supported for list comparison", op) + } +} + +func sortedMultisetEqual(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + as := toSortedStrings(a) + bs := toSortedStrings(b) + for i := range as { + if as[i] != bs[i] { + return false + } + } + return true +} + +func toSortedStrings(list []interface{}) []string { + s := make([]string, len(list)) + for i, v := range list { + s[i] = normalizeToString(v) + } + sort.Strings(s) + return s +} + +func compareBools(a, b bool, op string) (bool, error) { + switch op { + case "=": + return a == b, nil + case "!=": + return a != b, nil + default: + return false, fmt.Errorf("operator %s not supported for bool comparison", op) + } +} + +func compareStrings(a, b, op string) (bool, error) { + switch op { + case "=": + return a == b, nil + case "!=": + return a != b, nil + default: + return false, fmt.Errorf("operator %s not supported for string comparison", op) + } +} + +func compareStringsCI(a, b, op string) (bool, error) { + a = strings.ToUpper(a) + b = strings.ToUpper(b) + switch op { + case "=": + return a == b, nil + case "!=": + return a != b, nil + default: + return false, fmt.Errorf("operator %s not supported for id comparison", op) + } +} + +func compareIntValues(a, b int, op string) (bool, error) { + switch op { + case "=": + return a == b, nil + case "!=": + return a != b, nil + case "<": + return a < b, nil + case ">": + return a > b, nil + case "<=": + return a <= b, nil + case ">=": + return a >= b, nil + default: + return false, fmt.Errorf("unknown operator %q", op) + } +} + +func compareTimes(a, b time.Time, op string) (bool, error) { + switch op { + case "=": + return a.Equal(b), nil + case "!=": + return !a.Equal(b), nil + case "<": + return a.Before(b), nil + case ">": + return a.After(b), nil + case "<=": + return !a.After(b), nil + case ">=": + return !a.Before(b), nil + default: + return false, fmt.Errorf("unknown operator %q", op) + } +} + +func compareDurations(a, b time.Duration, op string) (bool, error) { + switch op { + case "=": + return a == b, nil + case "!=": + return a != b, nil + case "<": + return a < b, nil + case ">": + return a > b, nil + case "<=": + return a <= b, nil + case ">=": + return a >= b, nil + default: + return false, fmt.Errorf("unknown operator %q", op) + } +} + +// --- field extraction --- + +func extractField(t *task.Task, name string) interface{} { + switch name { + case "id": + return t.ID + case "title": + return t.Title + case "description": + return t.Description + case "status": + return t.Status + case "type": + return t.Type + case "priority": + return t.Priority + case "points": + return t.Points + case "tags": + return toInterfaceSlice(t.Tags) + case "dependsOn": + return toInterfaceSlice(t.DependsOn) + case "due": + return t.Due + case "recurrence": + return t.Recurrence + case "assignee": + return t.Assignee + case "createdBy": + return t.CreatedBy + case "createdAt": + return t.CreatedAt + case "updatedAt": + return t.UpdatedAt + default: + return nil + } +} + +// --- helpers --- + +func toInterfaceSlice(ss []string) []interface{} { + if ss == nil { + return []interface{}{} + } + result := make([]interface{}, len(ss)) + for i, s := range ss { + result[i] = s + } + return result +} + +func normalizeToString(v interface{}) string { + switch v := v.(type) { + case string: + return v + case task.Status: + return string(v) + case task.Type: + return string(v) + case task.Recurrence: + return string(v) + default: + return fmt.Sprint(v) + } +} + +func toInt(v interface{}) (int, bool) { + switch v := v.(type) { + case int: + return v, true + default: + return 0, false + } +} + +func isZeroValue(v interface{}) bool { + if v == nil { + return true + } + switch v := v.(type) { + case string: + return v == "" + case int: + return v == 0 + case time.Time: + return v.IsZero() + case time.Duration: + return v == 0 + case bool: + return !v + case task.Status: + return v == "" + case task.Type: + return v == "" + case task.Recurrence: + return v == "" + case []interface{}: + return len(v) == 0 + default: + return false + } +} + +func durationToTimeDelta(value int, unit string) time.Duration { + switch unit { + case "day": + return time.Duration(value) * 24 * time.Hour + case "week": + return time.Duration(value) * 7 * 24 * time.Hour + case "month": + return time.Duration(value) * 30 * 24 * time.Hour + case "year": + return time.Duration(value) * 365 * 24 * time.Hour + case "hour": + return time.Duration(value) * time.Hour + case "minute": + return time.Duration(value) * time.Minute + default: + return time.Duration(value) * 24 * time.Hour + } +} diff --git a/ruki/executor_test.go b/ruki/executor_test.go new file mode 100644 index 0000000..142bf22 --- /dev/null +++ b/ruki/executor_test.go @@ -0,0 +1,2146 @@ +package ruki + +import ( + "strings" + "testing" + "time" + + "github.com/boolean-maybe/tiki/task" +) + +func newTestExecutor() *Executor { + return NewExecutor(testSchema{}, func() string { return "alice" }) +} + +func testDate(m time.Month, d int) time.Time { + return time.Date(2026, m, d, 0, 0, 0, 0, time.UTC) +} + +func makeTasks() []*task.Task { + return []*task.Task{ + { + ID: "TIKI-000001", Title: "Setup CI", Status: "ready", Type: "story", + Priority: 2, Tags: []string{"infra"}, Assignee: "alice", + Due: testDate(4, 10), CreatedAt: testDate(3, 1), + }, + { + ID: "TIKI-000002", Title: "Fix login bug", Status: "in_progress", Type: "bug", + Priority: 1, Tags: []string{"bug", "frontend"}, Assignee: "bob", + Due: testDate(4, 5), DependsOn: []string{"TIKI-000001"}, + CreatedAt: testDate(3, 2), + }, + { + ID: "TIKI-000003", Title: "Write docs", Status: "done", Type: "story", + Priority: 3, Tags: []string{"docs"}, Assignee: "alice", + Due: testDate(4, 15), Points: 5, CreatedAt: testDate(3, 3), + }, + { + ID: "TIKI-000004", Title: "Plan sprint", Status: "backlog", Type: "spike", + Priority: 2, Tags: []string{}, Assignee: "", + CreatedAt: testDate(3, 4), + }, + } +} + +// --- nil guards --- + +func TestExecuteNilStatement(t *testing.T) { + e := newTestExecutor() + _, err := e.Execute(nil, nil) + if err == nil || !strings.Contains(err.Error(), "nil statement") { + t.Fatalf("expected 'nil statement' error, got: %v", err) + } +} + +func TestNewExecutorNilUserFunc(t *testing.T) { + e := NewExecutor(testSchema{}, nil) + tasks := []*task.Task{ + {ID: "T1", Title: "x", Status: "ready", Assignee: ""}, + } + stmt := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FieldRef{Name: "assignee"}, + Op: "=", + Right: &FunctionCall{Name: "user", Args: nil}, + }, + }, + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != 1 { + t.Fatalf("expected 1 task (empty assignee matches empty user()), got %d", len(result.Select.Tasks)) + } +} + +// --- basic select --- + +func TestExecuteSelectAll(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + tasks := makeTasks() + + stmt, err := p.ParseStatement("select") + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if result.Select == nil { + t.Fatal("expected Select result") + } + if len(result.Select.Tasks) != 4 { + t.Fatalf("expected 4 tasks, got %d", len(result.Select.Tasks)) + } + if result.Select.Fields != nil { + t.Fatalf("expected nil Fields, got %v", result.Select.Fields) + } +} + +func TestExecuteSelectWithFields(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + tasks := makeTasks() + + stmt, err := p.ParseStatement("select title, status") + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Fields) != 2 { + t.Fatalf("expected 2 fields, got %d", len(result.Select.Fields)) + } + if result.Select.Fields[0] != "title" || result.Select.Fields[1] != "status" { + t.Fatalf("unexpected fields: %v", result.Select.Fields) + } + if len(result.Select.Tasks) != 4 { + t.Fatalf("expected 4 tasks, got %d", len(result.Select.Tasks)) + } +} + +// --- WHERE filtering --- + +func TestExecuteSelectWhere(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + tasks := makeTasks() + + tests := []struct { + name string + input string + wantCount int + wantIDs []string + }{ + { + "status equals", `select where status = "done"`, + 1, []string{"TIKI-000003"}, + }, + { + "priority less than", `select where priority <= 2`, + 3, []string{"TIKI-000001", "TIKI-000002", "TIKI-000004"}, + }, + { + "and condition", `select where status = "ready" and priority = 2`, + 1, []string{"TIKI-000001"}, + }, + { + "or condition", `select where status = "done" or status = "backlog"`, + 2, []string{"TIKI-000003", "TIKI-000004"}, + }, + { + "not condition", `select where not status = "done"`, + 3, []string{"TIKI-000001", "TIKI-000002", "TIKI-000004"}, + }, + { + "in list", `select where status in ["done", "backlog"]`, + 2, []string{"TIKI-000003", "TIKI-000004"}, + }, + { + "not in list", `select where status not in ["done", "backlog"]`, + 2, []string{"TIKI-000001", "TIKI-000002"}, + }, + { + "value in tags", `select where "bug" in tags`, + 1, []string{"TIKI-000002"}, + }, + { + "is empty", `select where assignee is empty`, + 1, []string{"TIKI-000004"}, + }, + { + "is not empty", `select where assignee is not empty`, + 3, []string{"TIKI-000001", "TIKI-000002", "TIKI-000003"}, + }, + { + "tags is empty", `select where tags is empty`, + 1, []string{"TIKI-000004"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt, err := p.ParseStatement(tt.input) + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != tt.wantCount { + ids := make([]string, len(result.Select.Tasks)) + for i, tk := range result.Select.Tasks { + ids[i] = tk.ID + } + t.Fatalf("expected %d tasks, got %d: %v", tt.wantCount, len(result.Select.Tasks), ids) + } + for i, wantID := range tt.wantIDs { + if result.Select.Tasks[i].ID != wantID { + t.Errorf("task[%d].ID = %q, want %q", i, result.Select.Tasks[i].ID, wantID) + } + } + }) + } +} + +// --- ORDER BY --- + +func TestExecuteSelectOrderBy(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + tasks := makeTasks() + + tests := []struct { + name string + input string + wantIDs []string + }{ + { + "order by priority asc", + "select order by priority", + []string{"TIKI-000002", "TIKI-000001", "TIKI-000004", "TIKI-000003"}, + }, + { + "order by priority desc", + "select order by priority desc", + []string{"TIKI-000003", "TIKI-000001", "TIKI-000004", "TIKI-000002"}, + }, + { + "order by title asc", + "select order by title", + []string{"TIKI-000002", "TIKI-000004", "TIKI-000001", "TIKI-000003"}, + }, + { + "order by due", + "select order by due", + []string{"TIKI-000004", "TIKI-000002", "TIKI-000001", "TIKI-000003"}, + }, + { + "multi-field sort", + "select order by priority, createdAt", + []string{"TIKI-000002", "TIKI-000001", "TIKI-000004", "TIKI-000003"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt, err := p.ParseStatement(tt.input) + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != len(tt.wantIDs) { + t.Fatalf("expected %d tasks, got %d", len(tt.wantIDs), len(result.Select.Tasks)) + } + for i, wantID := range tt.wantIDs { + if result.Select.Tasks[i].ID != wantID { + t.Errorf("task[%d].ID = %q, want %q", i, result.Select.Tasks[i].ID, wantID) + } + } + }) + } +} + +func TestExecuteSelectNoOrderByPreservesInputOrder(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + + tasks := []*task.Task{ + {ID: "TIKI-CCC", Title: "C", Status: "ready", Priority: 1}, + {ID: "TIKI-AAA", Title: "A", Status: "ready", Priority: 1}, + {ID: "TIKI-BBB", Title: "B", Status: "ready", Priority: 1}, + } + + stmt, err := p.ParseStatement("select") + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + + wantIDs := []string{"TIKI-CCC", "TIKI-AAA", "TIKI-BBB"} + for i, wantID := range wantIDs { + if result.Select.Tasks[i].ID != wantID { + t.Errorf("task[%d].ID = %q, want %q — input order not preserved", i, result.Select.Tasks[i].ID, wantID) + } + } +} + +// --- enum normalization --- + +func TestExecuteEnumNormalization(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + + tasks := []*task.Task{ + {ID: "TIKI-A", Title: "A", Status: "done", Type: "story"}, + {ID: "TIKI-B", Title: "B", Status: "in_progress", Type: "bug"}, + {ID: "TIKI-C", Title: "C", Status: "ready", Type: "story"}, + } + + tests := []struct { + name string + input string + wantCount int + wantIDs []string + }{ + { + "status literal exact", `select where status = "done"`, + 1, []string{"TIKI-A"}, + }, + { + "status alias todo->ready", `select where status = "todo"`, + 1, []string{"TIKI-C"}, + }, + { + "status alias in progress", `select where status = "in progress"`, + 1, []string{"TIKI-B"}, + }, + { + "type alias feature->story", `select where type = "feature"`, + 2, []string{"TIKI-A", "TIKI-C"}, + }, + { + "type alias task->story", `select where type = "task"`, + 2, []string{"TIKI-A", "TIKI-C"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt, err := p.ParseStatement(tt.input) + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != tt.wantCount { + ids := make([]string, len(result.Select.Tasks)) + for i, tk := range result.Select.Tasks { + ids[i] = tk.ID + } + t.Fatalf("expected %d tasks, got %d: %v", tt.wantCount, len(result.Select.Tasks), ids) + } + for i, wantID := range tt.wantIDs { + if result.Select.Tasks[i].ID != wantID { + t.Errorf("task[%d].ID = %q, want %q", i, result.Select.Tasks[i].ID, wantID) + } + } + }) + } +} + +// --- ID case-insensitive comparison --- + +func TestExecuteIDCaseInsensitive(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + tasks := makeTasks() + + stmt, err := p.ParseStatement(`select where id = "tiki-000001"`) + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != 1 { + t.Fatalf("expected 1 task, got %d", len(result.Select.Tasks)) + } + if result.Select.Tasks[0].ID != "TIKI-000001" { + t.Fatalf("expected TIKI-000001, got %s", result.Select.Tasks[0].ID) + } +} + +// --- list set equality --- + +func TestExecuteListSetEquality(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + + tests := []struct { + name string + tasks []*task.Task + input string + wantCount int + }{ + { + "order-insensitive match", + []*task.Task{ + {ID: "T1", Title: "x", Status: "ready", Tags: []string{"a", "b"}}, + }, + `select where tags = ["b", "a"]`, + 1, + }, + { + "multiplicity matters — different lengths", + []*task.Task{ + {ID: "T1", Title: "x", Status: "ready", Tags: []string{"a"}}, + }, + `select where tags = ["a", "b"]`, + 0, + }, + { + "multiplicity matters — duplicate vs single", + []*task.Task{ + {ID: "T1", Title: "x", Status: "ready", Tags: []string{"a", "a"}}, + }, + `select where tags = ["a"]`, + 0, + }, + { + "empty list equality", + []*task.Task{ + {ID: "T1", Title: "x", Status: "ready", Tags: []string{}}, + }, + `select where tags = []`, + 1, + }, + { + "nil tags equals empty list", + []*task.Task{ + {ID: "T1", Title: "x", Status: "ready"}, + }, + `select where tags = []`, + 1, + }, + { + "list inequality", + []*task.Task{ + {ID: "T1", Title: "x", Status: "ready", Tags: []string{"a"}}, + }, + `select where tags != ["a", "b"]`, + 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: %v", err) + } + result, err := e.Execute(stmt, tt.tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != tt.wantCount { + t.Fatalf("expected %d tasks, got %d", tt.wantCount, len(result.Select.Tasks)) + } + }) + } +} + +// --- comparison matrix --- + +func TestExecuteComparisonMatrix(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + + tasks := []*task.Task{ + { + ID: "TIKI-A", Title: "Alpha", Status: "done", Type: "bug", + Priority: 5, Points: 3, Due: testDate(6, 15), + CreatedAt: testDate(1, 1), UpdatedAt: testDate(3, 1), + }, + } + + tests := []struct { + name string + input string + want bool + }{ + // string =, != + {"string eq match", `select where title = "Alpha"`, true}, + {"string eq no match", `select where title = "Beta"`, false}, + {"string neq", `select where title != "Beta"`, true}, + // int =, !=, <, >, <=, >= + {"int eq", `select where priority = 5`, true}, + {"int neq", `select where priority != 5`, false}, + {"int lt", `select where priority < 10`, true}, + {"int gt", `select where priority > 10`, false}, + {"int lte", `select where priority <= 5`, true}, + {"int gte", `select where priority >= 5`, true}, + // date ordering + {"date lt", `select where due < 2026-07-01`, true}, + {"date gt", `select where due > 2026-07-01`, false}, + {"date eq", `select where due = 2026-06-15`, true}, + // timestamp field-to-field + {"timestamp lt field", `select where createdAt < updatedAt`, true}, + {"timestamp eq self", `select where createdAt = createdAt`, true}, + // status =, != + {"status eq", `select where status = "done"`, true}, + {"status neq", `select where status != "done"`, false}, + // type =, != + {"type eq", `select where type = "bug"`, true}, + {"type neq", `select where type != "bug"`, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt, err := p.ParseStatement(tt.input) + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + got := len(result.Select.Tasks) > 0 + if got != tt.want { + t.Fatalf("expected match=%v, got %v", tt.want, got) + } + }) + } +} + +// --- functions --- + +func TestExecuteContains(t *testing.T) { + e := newTestExecutor() + tasks := makeTasks() + + // contains() returns bool; test via hand-built AST since the grammar + // has no bool literal for direct comparison in WHERE + stmt := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FunctionCall{ + Name: "contains", + Args: []Expr{&FieldRef{Name: "title"}, &StringLiteral{Value: "login"}}, + }, + Op: "=", + Right: &FunctionCall{ + Name: "contains", + Args: []Expr{&FieldRef{Name: "title"}, &StringLiteral{Value: "login"}}, + }, + }, + }, + } + + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + // all tasks match (bool = bool is always true for identical expressions) + if len(result.Select.Tasks) != 4 { + t.Fatalf("expected 4 tasks, got %d", len(result.Select.Tasks)) + } + + // test actual substring matching via a more targeted AST + stmt2 := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FunctionCall{ + Name: "contains", + Args: []Expr{&FieldRef{Name: "title"}, &StringLiteral{Value: "bug"}}, + }, + Op: "!=", + Right: &FunctionCall{ + Name: "contains", + Args: []Expr{&FieldRef{Name: "title"}, &StringLiteral{Value: "zzz"}}, + }, + }, + }, + } + + result, err = e.Execute(stmt2, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + // "Fix login bug" contains "bug" but not "zzz" → true != false → match + // others: contains(title,"bug")=false, contains(title,"zzz")=false → false != false → no match + if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "TIKI-000002" { + ids := make([]string, len(result.Select.Tasks)) + for i, tk := range result.Select.Tasks { + ids[i] = tk.ID + } + t.Fatalf("expected [TIKI-000002], got %v", ids) + } +} + +func TestExecuteUser(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + tasks := makeTasks() + + stmt, err := p.ParseStatement(`select where assignee = user()`) + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != 2 { + t.Fatalf("expected 2 tasks assigned to alice, got %d", len(result.Select.Tasks)) + } +} + +func TestExecuteCount(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + tasks := makeTasks() + + stmt, err := p.ParseStatement(`select where count(select where status = "done") >= 1`) + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + // count(done) = 1 which is >= 1, so the condition is true for every task + if len(result.Select.Tasks) != 4 { + t.Fatalf("expected 4 tasks, got %d", len(result.Select.Tasks)) + } +} + +func TestExecuteNextDate(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + + tasks := []*task.Task{ + { + ID: "T1", Title: "Daily", Status: "ready", + Recurrence: task.RecurrenceDaily, + }, + { + ID: "T2", Title: "No recurrence", Status: "ready", + Recurrence: task.RecurrenceNone, + }, + } + + stmt, err := p.ParseStatement(`select where next_date(recurrence) is not empty`) + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T1" { + t.Fatalf("expected T1, got %v", result.Select.Tasks) + } +} + +func TestExecuteBlocks(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + tasks := makeTasks() // TIKI-000002 depends on TIKI-000001 + + stmt, err := p.ParseStatement(`select where id in blocks("TIKI-000001")`) + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "TIKI-000002" { + t.Fatalf("expected TIKI-000002, got %v", result.Select.Tasks) + } +} + +// --- call() phase-1 rejection --- + +func TestExecuteCallRejected(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + tasks := makeTasks() + + stmt, err := p.ParseStatement(`select where call("echo hello") = "hello"`) + if err != nil { + t.Fatalf("parse: %v", err) + } + _, err = e.Execute(stmt, tasks) + if err == nil { + t.Fatal("expected error for call()") + } + if !strings.Contains(err.Error(), "call()") { + t.Fatalf("expected error mentioning call(), got: %v", err) + } +} + +// --- unsupported statement types --- + +func TestExecuteUnsupportedStatements(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + + tests := []struct { + name string + input string + }{ + {"create", `create title="test"`}, + {"update", `update where id = "X" set title="test"`}, + {"delete", `delete where id = "X"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt, err := p.ParseStatement(tt.input) + if err != nil { + t.Fatalf("parse: %v", err) + } + _, err = e.Execute(stmt, makeTasks()) + if err == nil { + t.Fatal("expected error for unsupported statement") + } + if !strings.Contains(err.Error(), "not supported") { + t.Fatalf("expected 'not supported' error, got: %v", err) + } + }) + } +} + +// --- quantifier --- + +func TestExecuteQuantifier(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + tasks := makeTasks() + + tests := []struct { + name string + input string + wantCount int + }{ + { + "any — dep not done", + `select where dependsOn any status != "done"`, + 1, // TIKI-000002 depends on TIKI-000001 (status=ready, not done) + }, + { + "all — all deps done (vacuously true for no deps)", + `select where dependsOn all status = "done"`, + 3, // TIKI-000001, TIKI-000003, TIKI-000004 (no deps = vacuous truth) + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt, err := p.ParseStatement(tt.input) + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != tt.wantCount { + ids := make([]string, len(result.Select.Tasks)) + for i, tk := range result.Select.Tasks { + ids[i] = tk.ID + } + t.Fatalf("expected %d tasks, got %d: %v", tt.wantCount, len(result.Select.Tasks), ids) + } + }) + } +} + +// --- date arithmetic in WHERE --- + +func TestExecuteDateArithmetic(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + + tasks := []*task.Task{ + {ID: "T1", Title: "Soon", Status: "ready", Due: testDate(4, 5)}, + {ID: "T2", Title: "Later", Status: "ready", Due: testDate(5, 1)}, + } + + stmt, err := p.ParseStatement(`select where due <= 2026-04-01 + 7day`) + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T1" { + t.Fatalf("expected T1, got %v", result.Select.Tasks) + } +} + +// --- qualified ref rejection --- + +func TestExecuteQualifiedRefRejected(t *testing.T) { + e := newTestExecutor() + + stmt := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &QualifiedRef{Qualifier: "old", Name: "status"}, + Op: "=", + Right: &StringLiteral{Value: "done"}, + }, + }, + } + + _, err := e.Execute(stmt, makeTasks()) + if err == nil { + t.Fatal("expected error for qualified ref") + } + if !strings.Contains(err.Error(), "qualified") { + t.Fatalf("expected qualified ref error, got: %v", err) + } +} + +// --- stable sort test --- + +func TestExecuteSortStable(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + + tasks := []*task.Task{ + {ID: "T1", Title: "First", Status: "ready", Priority: 1}, + {ID: "T2", Title: "Second", Status: "ready", Priority: 1}, + {ID: "T3", Title: "Third", Status: "ready", Priority: 1}, + {ID: "T4", Title: "Fourth", Status: "ready", Priority: 2}, + } + + stmt, err := p.ParseStatement("select order by priority") + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + + // priority=1 tasks should preserve input order: T1, T2, T3 + wantIDs := []string{"T1", "T2", "T3", "T4"} + for i, wantID := range wantIDs { + if result.Select.Tasks[i].ID != wantID { + t.Errorf("task[%d].ID = %q, want %q", i, result.Select.Tasks[i].ID, wantID) + } + } +} + +// --- empty statement --- + +func TestExecuteEmptyStatement(t *testing.T) { + e := newTestExecutor() + _, err := e.Execute(&Statement{}, nil) + if err == nil || !strings.Contains(err.Error(), "empty statement") { + t.Fatalf("expected 'empty statement' error, got: %v", err) + } +} + +// --- compareWithNil --- + +func TestExecuteCompareWithNil(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{ + {ID: "T1", Title: "x", Status: "ready", Assignee: ""}, + {ID: "T2", Title: "y", Status: "ready", Assignee: "bob"}, + } + + tests := []struct { + name string + op string + wantIDs []string + }{ + {"field = empty", "=", []string{"T1"}}, + {"field != empty", "!=", []string{"T2"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FieldRef{Name: "assignee"}, + Op: tt.op, + Right: &EmptyLiteral{}, + }, + }, + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != len(tt.wantIDs) { + t.Fatalf("expected %d tasks, got %d", len(tt.wantIDs), len(result.Select.Tasks)) + } + for i, wantID := range tt.wantIDs { + if result.Select.Tasks[i].ID != wantID { + t.Errorf("task[%d].ID = %q, want %q", i, result.Select.Tasks[i].ID, wantID) + } + } + }) + } + + // ordering op with nil returns false, no error + stmt := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FieldRef{Name: "assignee"}, + Op: "<", + Right: &EmptyLiteral{}, + }, + }, + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != 0 { + t.Fatalf("expected 0 tasks for < nil, got %d", len(result.Select.Tasks)) + } +} + +// --- duration comparison --- + +func TestExecuteDurationComparison(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{{ID: "T1", Title: "x", Status: "ready"}} + + tests := []struct { + name string + op string + l, r int + want bool + }{ + {"eq true", "=", 2, 2, true}, + {"eq false", "=", 1, 2, false}, + {"neq", "!=", 1, 2, true}, + {"lt", "<", 1, 2, true}, + {"gt", ">", 2, 1, true}, + {"lte", "<=", 2, 2, true}, + {"gte", ">=", 3, 2, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &DurationLiteral{Value: tt.l, Unit: "day"}, + Op: tt.op, + Right: &DurationLiteral{Value: tt.r, Unit: "day"}, + }, + }, + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + got := len(result.Select.Tasks) > 0 + if got != tt.want { + t.Fatalf("expected %v, got %v", tt.want, got) + } + }) + } +} + +// --- subtractValues --- + +func TestExecuteSubtractValues(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{ + {ID: "T1", Title: "x", Status: "ready", Priority: 10, Due: testDate(6, 15)}, + } + + tests := []struct { + name string + left Expr + right Expr + op string + cmp Expr + want bool + }{ + { + "int - int", + &BinaryExpr{Op: "-", Left: &FieldRef{Name: "priority"}, Right: &IntLiteral{Value: 5}}, + nil, "=", &IntLiteral{Value: 5}, true, + }, + { + "date - duration", + &BinaryExpr{Op: "-", Left: &FieldRef{Name: "due"}, Right: &DurationLiteral{Value: 1, Unit: "day"}}, + nil, "=", &DateLiteral{Value: testDate(6, 14)}, true, + }, + { + "date - date yields duration", + &BinaryExpr{ + Op: "-", + Left: &DateLiteral{Value: testDate(6, 17)}, + Right: &DateLiteral{Value: testDate(6, 15)}, + }, + nil, "=", &DurationLiteral{Value: 2, Unit: "day"}, true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{Left: tt.left, Op: tt.op, Right: tt.cmp}, + }, + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + got := len(result.Select.Tasks) > 0 + if got != tt.want { + t.Fatalf("expected %v, got %v", tt.want, got) + } + }) + } +} + +// --- addValues additional branches --- + +func TestExecuteAddValuesIntAndString(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{ + {ID: "T1", Title: "x", Status: "ready", Priority: 3}, + } + + // int + int + stmt := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &BinaryExpr{Op: "+", Left: &IntLiteral{Value: 2}, Right: &IntLiteral{Value: 3}}, + Op: "=", + Right: &IntLiteral{Value: 5}, + }, + }, + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != 1 { + t.Fatalf("expected 1, got %d", len(result.Select.Tasks)) + } + + // string + string + stmt2 := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &BinaryExpr{Op: "+", Left: &StringLiteral{Value: "hello"}, Right: &StringLiteral{Value: " world"}}, + Op: "=", + Right: &StringLiteral{Value: "hello world"}, + }, + }, + } + result, err = e.Execute(stmt2, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != 1 { + t.Fatalf("expected 1, got %d", len(result.Select.Tasks)) + } +} + +// --- sorting by status, type, recurrence, and nil fields --- + +func TestExecuteSortByStatusTypeRecurrence(t *testing.T) { + e := newTestExecutor() + + tasks := []*task.Task{ + {ID: "T1", Title: "a", Status: "done", Type: "bug", Recurrence: "0 0 * * *"}, + {ID: "T2", Title: "b", Status: "backlog", Type: "story", Recurrence: ""}, + {ID: "T3", Title: "c", Status: "ready", Type: "epic", Recurrence: "0 0 1 * *"}, + } + + tests := []struct { + name string + field string + wantIDs []string + }{ + {"by status", "status", []string{"T2", "T1", "T3"}}, + {"by type", "type", []string{"T1", "T3", "T2"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt := &Statement{ + Select: &SelectStmt{ + OrderBy: []OrderByClause{{Field: tt.field}}, + }, + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + for i, wantID := range tt.wantIDs { + if result.Select.Tasks[i].ID != wantID { + t.Errorf("task[%d].ID = %q, want %q", i, result.Select.Tasks[i].ID, wantID) + } + } + }) + } +} + +// --- extractField additional branches --- + +func TestExtractFieldAllFields(t *testing.T) { + tk := &task.Task{ + ID: "T1", Title: "hi", Description: "desc", Status: "ready", + Type: "bug", Priority: 1, Points: 3, Tags: []string{"a"}, + DependsOn: []string{"T2"}, Due: testDate(1, 1), + Recurrence: task.RecurrenceDaily, Assignee: "bob", + CreatedBy: "alice", CreatedAt: testDate(1, 1), UpdatedAt: testDate(2, 1), + } + + fields := []string{ + "id", "title", "description", "status", "type", "priority", + "points", "tags", "dependsOn", "due", "recurrence", "assignee", + "createdBy", "createdAt", "updatedAt", + } + for _, f := range fields { + v := extractField(tk, f) + if v == nil { + t.Errorf("extractField(%q) returned nil", f) + } + } + if v := extractField(tk, "nonexistent"); v != nil { + t.Errorf("extractField(nonexistent) should be nil, got %v", v) + } +} + +// --- isZeroValue full coverage --- + +func TestIsZeroValue(t *testing.T) { + tests := []struct { + name string + val interface{} + want bool + }{ + {"nil", nil, true}, + {"empty string", "", true}, + {"non-empty string", "x", false}, + {"zero int", 0, true}, + {"non-zero int", 1, false}, + {"zero time", time.Time{}, true}, + {"non-zero time", testDate(1, 1), false}, + {"zero duration", time.Duration(0), true}, + {"non-zero duration", time.Hour, false}, + {"false bool", false, true}, + {"true bool", true, false}, + {"empty status", task.Status(""), true}, + {"non-empty status", task.Status("done"), false}, + {"empty type", task.Type(""), true}, + {"non-empty type", task.Type("bug"), false}, + {"empty recurrence", task.Recurrence(""), true}, + {"non-empty recurrence", task.RecurrenceDaily, false}, + {"empty list", []interface{}{}, true}, + {"non-empty list", []interface{}{"a"}, false}, + {"unknown type", struct{}{}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isZeroValue(tt.val); got != tt.want { + t.Errorf("isZeroValue(%v) = %v, want %v", tt.val, got, tt.want) + } + }) + } +} + +// --- durationToTimeDelta full coverage --- + +func TestDurationToTimeDelta(t *testing.T) { + tests := []struct { + unit string + want time.Duration + }{ + {"day", 24 * time.Hour}, + {"week", 7 * 24 * time.Hour}, + {"month", 30 * 24 * time.Hour}, + {"year", 365 * 24 * time.Hour}, + {"hour", time.Hour}, + {"minute", time.Minute}, + {"unknown", 24 * time.Hour}, // default = day + } + for _, tt := range tests { + t.Run(tt.unit, func(t *testing.T) { + got := durationToTimeDelta(1, tt.unit) + if got != tt.want { + t.Errorf("durationToTimeDelta(1, %q) = %v, want %v", tt.unit, got, tt.want) + } + }) + } +} + +// --- normalizeToString coverage --- + +func TestNormalizeToString(t *testing.T) { + tests := []struct { + name string + val interface{} + want string + }{ + {"string", "hello", "hello"}, + {"status", task.Status("done"), "done"}, + {"type", task.Type("bug"), "bug"}, + {"recurrence", task.Recurrence("0 0 * * *"), "0 0 * * *"}, + {"int", 42, "42"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeToString(tt.val); got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +// --- compareForSort nil handling and all type branches --- + +func TestCompareForSort(t *testing.T) { + tests := []struct { + name string + a, b interface{} + want int + }{ + {"nil nil", nil, nil, 0}, + {"nil left", nil, 1, -1}, + {"nil right", 1, nil, 1}, + {"int lt", 1, 2, -1}, + {"int eq", 2, 2, 0}, + {"int gt", 3, 2, 1}, + {"string", "a", "b", -1}, + {"status", task.Status("a"), task.Status("b"), -1}, + {"type", task.Type("a"), task.Type("b"), -1}, + {"time before", testDate(1, 1), testDate(2, 1), -1}, + {"time equal", testDate(1, 1), testDate(1, 1), 0}, + {"time after", testDate(3, 1), testDate(2, 1), 1}, + {"recurrence", task.Recurrence("a"), task.Recurrence("b"), -1}, + {"duration lt", time.Hour, 2 * time.Hour, -1}, + {"duration eq", time.Hour, time.Hour, 0}, + {"duration gt", 2 * time.Hour, time.Hour, 1}, + {"fallback", struct{ x int }{1}, struct{ x int }{2}, -1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := compareForSort(tt.a, tt.b) + if got != tt.want { + t.Errorf("got %d, want %d", got, tt.want) + } + }) + } +} + +// --- toInt non-int branch --- + +func TestToInt(t *testing.T) { + if v, ok := toInt(42); !ok || v != 42 { + t.Errorf("expected (42, true), got (%d, %v)", v, ok) + } + if _, ok := toInt("not int"); ok { + t.Error("expected false for string") + } +} + +// --- count with nil where (counts all) --- + +func TestExecuteCountNoWhere(t *testing.T) { + e := newTestExecutor() + tasks := makeTasks() + + stmt := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FunctionCall{ + Name: "count", + Args: []Expr{&SubQuery{Where: nil}}, + }, + Op: "=", + Right: &IntLiteral{Value: 4}, + }, + }, + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != 4 { + t.Fatalf("expected 4, got %d", len(result.Select.Tasks)) + } +} + +// --- ID != comparison --- + +func TestExecuteIDNotEqual(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + tasks := []*task.Task{ + {ID: "TIKI-A", Title: "a", Status: "ready"}, + {ID: "TIKI-B", Title: "b", Status: "ready"}, + } + + stmt, err := p.ParseStatement(`select where id != "tiki-a"`) + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "TIKI-B" { + t.Fatalf("expected TIKI-B only, got %v", result.Select.Tasks) + } +} + +// --- recurrence field comparison --- + +func TestExecuteRecurrenceComparison(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{ + {ID: "T1", Title: "x", Status: "ready", Recurrence: task.RecurrenceDaily}, + {ID: "T2", Title: "y", Status: "ready", Recurrence: task.RecurrenceNone}, + } + + stmt := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FieldRef{Name: "recurrence"}, + Op: "=", + Right: &FieldRef{Name: "recurrence"}, + }, + }, + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != 2 { + t.Fatalf("expected 2, got %d", len(result.Select.Tasks)) + } +} + +// --- type normalization with unknown value fallback --- + +func TestExecuteNormalizeFallback(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{ + {ID: "T1", Title: "x", Status: "unknown_status_xyz", Type: "unknown_type_xyz"}, + } + + // status with unknown value passes through unchanged + stmt := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FieldRef{Name: "status"}, + Op: "=", + Right: &StringLiteral{Value: "unknown_status_xyz"}, + }, + }, + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != 1 { + t.Fatalf("expected 1, got %d", len(result.Select.Tasks)) + } + + // type with unknown value passes through unchanged + stmt2 := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FieldRef{Name: "type"}, + Op: "=", + Right: &StringLiteral{Value: "unknown_type_xyz"}, + }, + }, + } + result, err = e.Execute(stmt2, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != 1 { + t.Fatalf("expected 1, got %d", len(result.Select.Tasks)) + } +} + +// --- time comparison all operators --- + +func TestExecuteTimeComparisonAllOps(t *testing.T) { + e := newTestExecutor() + d1 := testDate(3, 1) + d2 := testDate(6, 1) + tasks := []*task.Task{{ID: "T1", Title: "x", Status: "ready"}} + + tests := []struct { + name string + op string + l, r time.Time + want bool + }{ + {"eq", "=", d1, d1, true}, + {"neq", "!=", d1, d2, true}, + {"lt", "<", d1, d2, true}, + {"gt", ">", d2, d1, true}, + {"lte", "<=", d1, d1, true}, + {"gte", ">=", d1, d1, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &DateLiteral{Value: tt.l}, + Op: tt.op, + Right: &DateLiteral{Value: tt.r}, + }, + }, + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + got := len(result.Select.Tasks) > 0 + if got != tt.want { + t.Fatalf("expected %v, got %v", tt.want, got) + } + }) + } +} + +// --- error propagation tests --- + +func TestExecuteErrorPropagation(t *testing.T) { + e := newTestExecutor() + tasks := makeTasks() + + badExpr := &QualifiedRef{Qualifier: "old", Name: "status"} + + tests := []struct { + name string + stmt *Statement + }{ + { + "evalCompare left error", + &Statement{Select: &SelectStmt{ + Where: &CompareExpr{Left: badExpr, Op: "=", Right: &StringLiteral{Value: "x"}}, + }}, + }, + { + "evalCompare right error", + &Statement{Select: &SelectStmt{ + Where: &CompareExpr{Left: &StringLiteral{Value: "x"}, Op: "=", Right: badExpr}, + }}, + }, + { + "evalIsEmpty error", + &Statement{Select: &SelectStmt{ + Where: &IsEmptyExpr{Expr: badExpr}, + }}, + }, + { + "evalIn value error", + &Statement{Select: &SelectStmt{ + Where: &InExpr{Value: badExpr, Collection: &FieldRef{Name: "tags"}}, + }}, + }, + { + "evalIn collection error", + &Statement{Select: &SelectStmt{ + Where: &InExpr{Value: &StringLiteral{Value: "x"}, Collection: badExpr}, + }}, + }, + { + "evalQuantifier expr error", + &Statement{Select: &SelectStmt{ + Where: &QuantifierExpr{Expr: badExpr, Kind: "any", Condition: &CompareExpr{Left: &IntLiteral{Value: 1}, Op: "=", Right: &IntLiteral{Value: 1}}}, + }}, + }, + { + "evalBinaryExpr left error", + &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &BinaryExpr{Op: "+", Left: badExpr, Right: &IntLiteral{Value: 1}}, + Op: "=", + Right: &IntLiteral{Value: 1}, + }, + }}, + }, + { + "evalBinaryExpr right error", + &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &BinaryExpr{Op: "+", Left: &IntLiteral{Value: 1}, Right: badExpr}, + Op: "=", + Right: &IntLiteral{Value: 1}, + }, + }}, + }, + { + "evalListLiteral element error", + &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &ListLiteral{Elements: []Expr{badExpr}}, + Op: "=", + Right: &ListLiteral{Elements: []Expr{&StringLiteral{Value: "x"}}}, + }, + }}, + }, + { + "unknown function", + &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FunctionCall{Name: "nonexistent", Args: nil}, + Op: "=", + Right: &IntLiteral{Value: 1}, + }, + }}, + }, + { + "subquery not in count", + &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &SubQuery{Where: nil}, + Op: "=", + Right: &IntLiteral{Value: 1}, + }, + }}, + }, + { + "binary expr unknown op", + &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &BinaryExpr{Op: "*", Left: &IntLiteral{Value: 1}, Right: &IntLiteral{Value: 1}}, + Op: "=", + Right: &IntLiteral{Value: 1}, + }, + }}, + }, + { + "add type mismatch", + &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &BinaryExpr{Op: "+", Left: &IntLiteral{Value: 1}, Right: &StringLiteral{Value: "x"}}, + Op: "=", + Right: &IntLiteral{Value: 1}, + }, + }}, + }, + { + "subtract type mismatch", + &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &BinaryExpr{Op: "-", Left: &IntLiteral{Value: 1}, Right: &StringLiteral{Value: "x"}}, + Op: "=", + Right: &IntLiteral{Value: 1}, + }, + }}, + }, + { + "not condition inner error", + &Statement{Select: &SelectStmt{ + Where: &NotCondition{Inner: &CompareExpr{Left: badExpr, Op: "=", Right: &StringLiteral{Value: "x"}}}, + }}, + }, + { + "binary condition left error", + &Statement{Select: &SelectStmt{ + Where: &BinaryCondition{ + Op: "and", + Left: &CompareExpr{Left: badExpr, Op: "=", Right: &StringLiteral{Value: "x"}}, + Right: &CompareExpr{Left: &IntLiteral{Value: 1}, Op: "=", Right: &IntLiteral{Value: 1}}, + }, + }}, + }, + { + "contains haystack error", + &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FunctionCall{Name: "contains", Args: []Expr{badExpr, &StringLiteral{Value: "x"}}}, + Op: "=", + Right: &FunctionCall{Name: "contains", Args: []Expr{&StringLiteral{Value: "y"}, &StringLiteral{Value: "x"}}}, + }, + }}, + }, + { + "contains needle error", + &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FunctionCall{Name: "contains", Args: []Expr{&StringLiteral{Value: "y"}, badExpr}}, + Op: "=", + Right: &FunctionCall{Name: "contains", Args: []Expr{&StringLiteral{Value: "y"}, &StringLiteral{Value: "x"}}}, + }, + }}, + }, + { + "next_date arg error", + &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FunctionCall{Name: "next_date", Args: []Expr{badExpr}}, + Op: "=", + Right: &DateLiteral{Value: testDate(1, 1)}, + }, + }}, + }, + { + "blocks arg error", + &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FunctionCall{Name: "blocks", Args: []Expr{badExpr}}, + Op: "=", + Right: &ListLiteral{Elements: nil}, + }, + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := e.Execute(tt.stmt, tasks) + if err == nil { + t.Fatal("expected error") + } + }) + } +} + +// --- in with non-list collection --- + +func TestExecuteInNonListCollection(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{{ID: "T1", Title: "x", Status: "ready"}} + + stmt := &Statement{ + Select: &SelectStmt{ + Where: &InExpr{ + Value: &StringLiteral{Value: "x"}, + Collection: &StringLiteral{Value: "not a list"}, + }, + }, + } + _, err := e.Execute(stmt, tasks) + if err == nil || !strings.Contains(err.Error(), "not a list") { + t.Fatalf("expected 'not a list' error, got: %v", err) + } +} + +// --- quantifier with non-list expression --- + +func TestExecuteQuantifierNonList(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{{ID: "T1", Title: "x", Status: "ready"}} + + stmt := &Statement{ + Select: &SelectStmt{ + Where: &QuantifierExpr{ + Expr: &StringLiteral{Value: "not a list"}, + Kind: "any", + Condition: &CompareExpr{Left: &IntLiteral{Value: 1}, Op: "=", Right: &IntLiteral{Value: 1}}, + }, + }, + } + _, err := e.Execute(stmt, tasks) + if err == nil || !strings.Contains(err.Error(), "not a list") { + t.Fatalf("expected 'not a list' error, got: %v", err) + } +} + +// --- next_date with non-recurrence value --- + +func TestExecuteNextDateNonRecurrence(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{{ID: "T1", Title: "x", Status: "ready"}} + + stmt := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FunctionCall{Name: "next_date", Args: []Expr{&StringLiteral{Value: "not recurrence"}}}, + Op: "=", + Right: &DateLiteral{Value: testDate(1, 1)}, + }, + }, + } + _, err := e.Execute(stmt, tasks) + if err == nil || !strings.Contains(err.Error(), "recurrence") { + t.Fatalf("expected recurrence error, got: %v", err) + } +} + +// --- count non-subquery arg --- + +func TestExecuteCountNonSubquery(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{{ID: "T1", Title: "x", Status: "ready"}} + + stmt := &Statement{ + Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FunctionCall{Name: "count", Args: []Expr{&IntLiteral{Value: 1}}}, + Op: "=", + Right: &IntLiteral{Value: 1}, + }, + }, + } + _, err := e.Execute(stmt, tasks) + if err == nil || !strings.Contains(err.Error(), "subquery") { + t.Fatalf("expected subquery error, got: %v", err) + } +} + +// --- quantifier condition error propagation --- + +func TestExecuteQuantifierConditionError(t *testing.T) { + e := newTestExecutor() + badCond := &CompareExpr{Left: &QualifiedRef{Qualifier: "old", Name: "status"}, Op: "=", Right: &StringLiteral{Value: "x"}} + + tasks := []*task.Task{ + {ID: "T1", Title: "x", Status: "ready", DependsOn: []string{"T2"}}, + {ID: "T2", Title: "y", Status: "done"}, + } + + // any with error in condition + stmt := &Statement{Select: &SelectStmt{ + Where: &QuantifierExpr{Expr: &FieldRef{Name: "dependsOn"}, Kind: "any", Condition: badCond}, + }} + _, err := e.Execute(stmt, tasks) + if err == nil { + t.Fatal("expected error from quantifier any") + } + + // all with error in condition + stmt2 := &Statement{Select: &SelectStmt{ + Where: &QuantifierExpr{Expr: &FieldRef{Name: "dependsOn"}, Kind: "all", Condition: badCond}, + }} + _, err = e.Execute(stmt2, tasks) + if err == nil { + t.Fatal("expected error from quantifier all") + } + + // unknown quantifier kind + stmt3 := &Statement{Select: &SelectStmt{ + Where: &QuantifierExpr{ + Expr: &FieldRef{Name: "dependsOn"}, Kind: "none", + Condition: &CompareExpr{Left: &IntLiteral{Value: 1}, Op: "=", Right: &IntLiteral{Value: 1}}, + }, + }} + _, err = e.Execute(stmt3, tasks) + if err == nil || !strings.Contains(err.Error(), "unknown quantifier") { + t.Fatalf("expected unknown quantifier error, got: %v", err) + } +} + +// --- count subquery condition error --- + +func TestExecuteCountSubqueryError(t *testing.T) { + e := newTestExecutor() + tasks := makeTasks() + + stmt := &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FunctionCall{Name: "count", Args: []Expr{ + &SubQuery{Where: &CompareExpr{ + Left: &QualifiedRef{Qualifier: "old", Name: "status"}, Op: "=", Right: &StringLiteral{Value: "x"}, + }}, + }}, + Op: "=", + Right: &IntLiteral{Value: 1}, + }, + }} + _, err := e.Execute(stmt, tasks) + if err == nil { + t.Fatal("expected error from count subquery") + } +} + +// --- resolveComparisonType right-side field --- + +func TestExecuteResolveComparisonTypeRightSide(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{ + {ID: "TIKI-A", Title: "x", Status: "ready"}, + } + + // literal on left, field on right — resolveComparisonType checks right side + stmt := &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &StringLiteral{Value: "tiki-a"}, + Op: "=", + Right: &FieldRef{Name: "id"}, + }, + }} + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != 1 { + t.Fatalf("expected 1, got %d", len(result.Select.Tasks)) + } + + // literal on left, status field on right + stmt2 := &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &StringLiteral{Value: "todo"}, + Op: "=", + Right: &FieldRef{Name: "status"}, + }, + }} + result, err = e.Execute(stmt2, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(result.Select.Tasks) != 1 { + t.Fatalf("expected 1 (todo->ready), got %d", len(result.Select.Tasks)) + } + + // literal on left, type field on right + stmt3 := &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &StringLiteral{Value: "feature"}, + Op: "!=", + Right: &FieldRef{Name: "type"}, + }, + }} + result, err = e.Execute(stmt3, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + // status is "ready", type is "" — "feature" normalizes to "story", "" doesn't normalize → not equal + if len(result.Select.Tasks) != 1 { + t.Fatalf("expected 1, got %d", len(result.Select.Tasks)) + } +} + +// --- exprFieldType with unknown field --- + +func TestExprFieldType(t *testing.T) { + e := newTestExecutor() + // non-FieldRef returns -1 + if got := e.exprFieldType(&StringLiteral{Value: "x"}); got != -1 { + t.Errorf("expected -1 for StringLiteral, got %d", got) + } + // unknown field returns -1 + if got := e.exprFieldType(&FieldRef{Name: "nonexistent"}); got != -1 { + t.Errorf("expected -1 for unknown field, got %d", got) + } + // known field returns its type + if got := e.exprFieldType(&FieldRef{Name: "status"}); got != ValueStatus { + t.Errorf("expected ValueStatus, got %d", got) + } +} + +// --- unknown condition type --- + +type fakeCondition struct{} + +func (*fakeCondition) conditionNode() {} + +func TestExecuteUnknownConditionType(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{{ID: "T1", Title: "x", Status: "ready"}} + + stmt := &Statement{Select: &SelectStmt{Where: &fakeCondition{}}} + _, err := e.Execute(stmt, tasks) + if err == nil || !strings.Contains(err.Error(), "unknown condition type") { + t.Fatalf("expected unknown condition type error, got: %v", err) + } +} + +// --- unknown binary condition operator --- + +func TestExecuteUnknownBinaryConditionOp(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{{ID: "T1", Title: "x", Status: "ready"}} + + stmt := &Statement{Select: &SelectStmt{ + Where: &BinaryCondition{ + Op: "xor", + Left: &CompareExpr{Left: &IntLiteral{Value: 1}, Op: "=", Right: &IntLiteral{Value: 1}}, + Right: &CompareExpr{Left: &IntLiteral{Value: 1}, Op: "=", Right: &IntLiteral{Value: 1}}, + }, + }} + _, err := e.Execute(stmt, tasks) + if err == nil || !strings.Contains(err.Error(), "unknown binary operator") { + t.Fatalf("expected unknown binary operator error, got: %v", err) + } +} + +// --- unknown expression type --- + +type fakeExpr struct{} + +func (*fakeExpr) exprNode() {} + +func TestExecuteUnknownExprType(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{{ID: "T1", Title: "x", Status: "ready"}} + + stmt := &Statement{Select: &SelectStmt{ + Where: &CompareExpr{Left: &fakeExpr{}, Op: "=", Right: &IntLiteral{Value: 1}}, + }} + _, err := e.Execute(stmt, tasks) + if err == nil || !strings.Contains(err.Error(), "unknown expression type") { + t.Fatalf("expected unknown expression type error, got: %v", err) + } +} + +// --- blocks returning empty list --- + +func TestExecuteBlocksNoBlockers(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{ + {ID: "T1", Title: "x", Status: "ready"}, + {ID: "T2", Title: "y", Status: "ready"}, + } + + stmt := &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FunctionCall{Name: "blocks", Args: []Expr{&FieldRef{Name: "id"}}}, + Op: "=", + Right: &ListLiteral{Elements: nil}, + }, + }} + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + // no task depends on any other, so blocks() returns [] for all → all match + if len(result.Select.Tasks) != 2 { + t.Fatalf("expected 2, got %d", len(result.Select.Tasks)) + } +} + +// --- now() function --- + +func TestExecuteNow(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{ + {ID: "T1", Title: "x", Status: "ready", Due: testDate(12, 31)}, + } + + stmt := &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &FieldRef{Name: "due"}, + Op: ">", + Right: &FunctionCall{Name: "now", Args: nil}, + }, + }} + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + // 2026-12-31 should be after now + if len(result.Select.Tasks) != 1 { + t.Fatalf("expected 1, got %d", len(result.Select.Tasks)) + } +} + +// --- sort by status and type (covers compareForSort branches already +// tested via TestCompareForSort, but exercises them through sortTasks) --- + +func TestExecuteSortByStatus(t *testing.T) { + e := newTestExecutor() + + tasks := []*task.Task{ + {ID: "T1", Title: "a", Status: "ready"}, + {ID: "T2", Title: "b", Status: "done"}, + {ID: "T3", Title: "c", Status: "backlog"}, + } + + stmt := &Statement{Select: &SelectStmt{ + OrderBy: []OrderByClause{{Field: "status", Desc: true}}, + }} + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + // desc: ready > done > backlog + if result.Select.Tasks[0].ID != "T1" { + t.Errorf("expected T1 first (ready), got %s", result.Select.Tasks[0].ID) + } +} + +// --- compareValues unsupported type fallback --- + +func TestExecuteCompareUnsupportedType(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{{ID: "T1", Title: "x", Status: "ready"}} + + // DurationLiteral on left compared with int on right — type mismatch + stmt := &Statement{Select: &SelectStmt{ + Where: &CompareExpr{ + Left: &DurationLiteral{Value: 1, Unit: "day"}, + Op: "=", + Right: &IntLiteral{Value: 1}, + }, + }} + _, err := e.Execute(stmt, tasks) + if err == nil { + t.Fatal("expected type mismatch error") + } +} + +// --- comparison helper error branches --- + +func TestComparisonHelperErrors(t *testing.T) { + // all comparison helpers reject unknown operators + if _, err := compareStrings("a", "b", "<"); err == nil { + t.Error("compareStrings should reject <") + } + if _, err := compareStringsCI("a", "b", "<"); err == nil { + t.Error("compareStringsCI should reject <") + } + if _, err := compareBools(true, false, "<"); err == nil { + t.Error("compareBools should reject <") + } + if _, err := compareIntValues(1, 2, "~"); err == nil { + t.Error("compareIntValues should reject ~") + } + if _, err := compareTimes(time.Now(), time.Now(), "~"); err == nil { + t.Error("compareTimes should reject ~") + } + if _, err := compareDurations(time.Hour, time.Hour, "~"); err == nil { + t.Error("compareDurations should reject ~") + } + if _, err := compareListEquality(nil, nil, "<"); err == nil { + t.Error("compareListEquality should reject <") + } +} + +// --- sortedMultisetEqual with same-length but different elements --- + +func TestSortedMultisetEqualMismatch(t *testing.T) { + a := []interface{}{"x", "y"} + b := []interface{}{"x", "z"} + if sortedMultisetEqual(a, b) { + t.Error("expected false for different elements") + } +} + +// --- compareValues with task.Status/Type falling through (non-field context) --- + +func TestCompareValuesStatusTypeDirect(t *testing.T) { + e := newTestExecutor() + + // status value vs string without FieldRef context — falls through to task.Status case + ok, err := e.compareValues(task.Status("done"), "done", "=", &StringLiteral{Value: "done"}, &StringLiteral{Value: "done"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Error("expected status done = done") + } + + // type value vs string + ok, err = e.compareValues(task.Type("bug"), "bug", "=", &StringLiteral{Value: "bug"}, &StringLiteral{Value: "bug"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Error("expected type bug = bug") + } + + // int vs non-int + _, err = e.compareValues(42, "not int", "=", &IntLiteral{Value: 42}, &StringLiteral{Value: "not int"}) + if err == nil { + t.Error("expected error for int vs string") + } + + // time vs non-time + _, err = e.compareValues(time.Now(), "not time", "=", &DateLiteral{Value: time.Now()}, &StringLiteral{Value: "not time"}) + if err == nil { + t.Error("expected error for time vs string") + } + + // unsupported type + _, err = e.compareValues(struct{}{}, struct{}{}, "=", &IntLiteral{Value: 1}, &IntLiteral{Value: 1}) + if err == nil { + t.Error("expected error for unsupported type") + } +} + +// --- evalQuantifier: all where one ref fails condition --- + +func TestExecuteQuantifierAllFailing(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{ + {ID: "T1", Title: "x", Status: "ready", DependsOn: []string{"T2", "T3"}}, + {ID: "T2", Title: "y", Status: "done"}, + {ID: "T3", Title: "z", Status: "ready"}, + } + + stmt, err := newTestParser().ParseStatement(`select where dependsOn all status = "done"`) + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + // T1 depends on T2(done) and T3(ready) — not all done + // T2 has no deps → vacuous truth + // T3 has no deps → vacuous truth + wantCount := 2 + if len(result.Select.Tasks) != wantCount { + ids := make([]string, len(result.Select.Tasks)) + for i, tk := range result.Select.Tasks { + ids[i] = tk.ID + } + t.Fatalf("expected %d tasks, got %d: %v", wantCount, len(result.Select.Tasks), ids) + } +} + +func TestExecuteQuantifierAllPassing(t *testing.T) { + e := newTestExecutor() + tasks := []*task.Task{ + {ID: "T1", Title: "x", Status: "ready", DependsOn: []string{"T2", "T3"}}, + {ID: "T2", Title: "y", Status: "done"}, + {ID: "T3", Title: "z", Status: "done"}, + } + + stmt, err := newTestParser().ParseStatement(`select where dependsOn all status = "done"`) + if err != nil { + t.Fatalf("parse: %v", err) + } + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + // all 3: T1 deps are T2+T3 both done, T2+T3 have no deps (vacuous) + if len(result.Select.Tasks) != 3 { + ids := make([]string, len(result.Select.Tasks)) + for i, tk := range result.Select.Tasks { + ids[i] = tk.ID + } + t.Fatalf("expected 3 tasks, got %d: %v", len(result.Select.Tasks), ids) + } +} diff --git a/ruki/keyword/keyword.go b/ruki/keyword/keyword.go new file mode 100644 index 0000000..68406ac --- /dev/null +++ b/ruki/keyword/keyword.go @@ -0,0 +1,42 @@ +package keyword + +import "strings" + +// reserved is the canonical, immutable list of ruki reserved words. +var reserved = [...]string{ + "select", "create", "update", "delete", + "where", "set", "order", "by", + "asc", "desc", + "before", "after", "deny", "run", + "and", "or", "not", + "is", "empty", "in", + "any", "all", + "old", "new", +} + +var reservedSet map[string]struct{} + +func init() { + reservedSet = make(map[string]struct{}, len(reserved)) + for _, kw := range reserved { + reservedSet[strings.ToLower(kw)] = struct{}{} + } +} + +// IsReserved reports whether name is a ruki reserved word (case-insensitive). +func IsReserved(name string) bool { + _, ok := reservedSet[strings.ToLower(name)] + return ok +} + +// List returns a copy of the reserved keyword list. +func List() []string { + result := make([]string, len(reserved)) + copy(result, reserved[:]) + return result +} + +// Pattern returns the regex alternation for the lexer Keyword rule. +func Pattern() string { + return `\b(` + strings.Join(reserved[:], "|") + `)\b` +} diff --git a/ruki/keywords.go b/ruki/keywords.go index 5adac73..f69c04e 100644 --- a/ruki/keywords.go +++ b/ruki/keywords.go @@ -1,45 +1,18 @@ package ruki -import "strings" - -// reservedKeywords is the canonical, immutable list of ruki reserved words. -// the lexer regex and IsReservedKeyword both derive from this single source. -var reservedKeywords = [...]string{ - "select", "create", "update", "delete", - "where", "set", "order", "by", - "asc", "desc", - "before", "after", "deny", "run", - "and", "or", "not", - "is", "empty", "in", - "any", "all", - "old", "new", -} - -// reservedSet is a case-insensitive lookup built from reservedKeywords. -var reservedSet map[string]struct{} - -func init() { - reservedSet = make(map[string]struct{}, len(reservedKeywords)) - for _, kw := range reservedKeywords { - reservedSet[strings.ToLower(kw)] = struct{}{} - } -} +import "github.com/boolean-maybe/tiki/ruki/keyword" // IsReservedKeyword reports whether name is a ruki reserved word (case-insensitive). func IsReservedKeyword(name string) bool { - _, ok := reservedSet[strings.ToLower(name)] - return ok + return keyword.IsReserved(name) } // ReservedKeywordsList returns a copy of the reserved keyword list. func ReservedKeywordsList() []string { - result := make([]string, len(reservedKeywords)) - copy(result, reservedKeywords[:]) - return result + return keyword.List() } // keywordPattern returns the regex alternation for the lexer Keyword rule. -// called once during lexer init. func keywordPattern() string { - return `\b(` + strings.Join(reservedKeywords[:], "|") + `)\b` + return keyword.Pattern() } diff --git a/ruki/lexer_test.go b/ruki/lexer_test.go index 446429a..8b88fec 100644 --- a/ruki/lexer_test.go +++ b/ruki/lexer_test.go @@ -32,7 +32,7 @@ func tokenize(t *testing.T, input string) []lexer.Token { func TestTokenizeKeywords(t *testing.T) { keywordType := rukiLexer.Symbols()["Keyword"] - for _, kw := range reservedKeywords { + for _, kw := range ReservedKeywordsList() { t.Run(kw, func(t *testing.T) { tokens := tokenize(t, kw) if len(tokens) != 1 { diff --git a/workflow/fields.go b/workflow/fields.go index a73d6a1..d119544 100644 --- a/workflow/fields.go +++ b/workflow/fields.go @@ -3,7 +3,7 @@ package workflow import ( "fmt" - "github.com/boolean-maybe/tiki/ruki" + "github.com/boolean-maybe/tiki/ruki/keyword" ) // ValueType identifies the semantic type of a task field. @@ -56,7 +56,7 @@ var fieldByName map[string]FieldDef func init() { fieldByName = make(map[string]FieldDef, len(fieldCatalog)) for _, f := range fieldCatalog { - if ruki.IsReservedKeyword(f.Name) { + if keyword.IsReserved(f.Name) { panic(fmt.Sprintf("field catalog contains reserved keyword: %q", f.Name)) } fieldByName[f.Name] = f @@ -71,7 +71,7 @@ func Field(name string) (FieldDef, bool) { // ValidateFieldName rejects names that collide with ruki reserved keywords. func ValidateFieldName(name string) error { - if ruki.IsReservedKeyword(name) { + if keyword.IsReserved(name) { return fmt.Errorf("field name %q is reserved", name) } return nil diff --git a/workflow/fields_test.go b/workflow/fields_test.go index 18776d0..77ebb7b 100644 --- a/workflow/fields_test.go +++ b/workflow/fields_test.go @@ -3,7 +3,7 @@ package workflow import ( "testing" - "github.com/boolean-maybe/tiki/ruki" + "github.com/boolean-maybe/tiki/ruki/keyword" ) func TestField(t *testing.T) { @@ -78,7 +78,7 @@ func TestDateVsTimestamp(t *testing.T) { } func TestValidateFieldName_RejectsKeywords(t *testing.T) { - for _, kw := range ruki.ReservedKeywordsList() { + for _, kw := range keyword.List() { t.Run(kw, func(t *testing.T) { err := ValidateFieldName(kw) if err == nil {