replace plugin expressions with ruki

This commit is contained in:
booleanmaybe 2026-04-08 22:46:46 -04:00
parent 65215da3b0
commit 00ba1ed8ec
38 changed files with 488 additions and 4703 deletions

View file

@ -29,18 +29,17 @@ views:
key: "F1"
lanes:
- name: Ready
filter: status = 'ready' and type != 'epic'
action: status = 'ready'
filter: select where status = "ready" and type != "epic" order by priority, createdAt
action: update where id = id() set status="ready"
- name: In Progress
filter: status = 'inProgress' and type != 'epic'
action: status = 'inProgress'
filter: select where status = "inProgress" and type != "epic" order by priority, createdAt
action: update where id = id() set status="inProgress"
- name: Review
filter: status = 'review' and type != 'epic'
action: status = 'review'
filter: select where status = "review" and type != "epic" order by priority, createdAt
action: update where id = id() set status="review"
- name: Done
filter: status = 'done' and type != 'epic'
action: status = 'done'
sort: Priority, CreatedAt
filter: select where status = "done" and type != "epic" order by priority, createdAt
action: update where id = id() set status="done"
- name: Backlog
description: "Tasks waiting to be picked up, sorted by priority"
foreground: "#5fff87"
@ -49,12 +48,11 @@ views:
lanes:
- name: Backlog
columns: 4
filter: status = 'backlog' and type != 'epic'
filter: select where status = "backlog" and type != "epic" order by priority, id
actions:
- key: "b"
label: "Add to board"
action: status = 'ready'
sort: Priority, ID
action: update where id = id() set status="ready"
- name: Recent
description: "Tasks changed in the last 24 hours, most recent first"
foreground: "#f4d6a6"
@ -63,8 +61,7 @@ views:
lanes:
- name: Recent
columns: 4
filter: NOW - UpdatedAt < 24hours
sort: UpdatedAt DESC
filter: select where now() - updatedAt < 24hour order by updatedAt desc
- name: Roadmap
description: "Epics organized by Now, Next, and Later horizons"
foreground: "#e2e8f0"
@ -74,19 +71,18 @@ views:
- name: Now
columns: 1
width: 25
filter: type = 'epic' AND status = 'ready'
action: status = 'ready'
filter: select where type = "epic" and status = "ready" order by priority, points desc
action: update where id = id() set status="ready"
- name: Next
columns: 1
width: 25
filter: type = 'epic' AND status = 'backlog' AND priority = 1
action: status = 'backlog', priority = 1
filter: select where type = "epic" and status = "backlog" and priority = 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=1
- name: Later
columns: 2
width: 50
filter: type = 'epic' AND status = 'backlog' AND priority > 1
action: status = 'backlog', priority = 2
sort: Priority, Points DESC
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
action: update where id = id() set status="backlog" priority=2
view: expanded
- name: Help
description: "Keyboard shortcuts, navigation, and usage guide"

View file

@ -2,11 +2,13 @@ package controller
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/service"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/task"
@ -34,6 +36,7 @@ func NewDepsController(
pluginDef *plugin.TikiPlugin,
navController *NavigationController,
statusline *model.StatuslineConfig,
schema ruki.Schema,
) *DepsController {
return &DepsController{
pluginBase: pluginBase{
@ -44,6 +47,7 @@ func NewDepsController(
navController: navController,
statusline: statusline,
registry: DepsViewActions(),
schema: schema,
},
}
}
@ -164,39 +168,42 @@ func (dc *DepsController) handleMoveTask(offset int) bool {
contextTaskID := dc.pluginDef.TaskID
// determine which tasks to update and how
type update struct {
taskID string
action plugin.LaneAction
}
var updates []update
// build a ruki UPDATE query for the dependency change
var query string
switch {
case sourceLane == depsLaneAll && targetLane == depsLaneBlocks:
updates = append(updates, update{movedTaskID, depsAction(plugin.ActionOperatorAdd, contextTaskID)})
query = fmt.Sprintf(`update where id = "%s" set dependsOn=dependsOn+["%s"]`, movedTaskID, contextTaskID)
case sourceLane == depsLaneAll && targetLane == depsLaneDepends:
updates = append(updates, update{contextTaskID, depsAction(plugin.ActionOperatorAdd, movedTaskID)})
query = fmt.Sprintf(`update where id = "%s" set dependsOn=dependsOn+["%s"]`, contextTaskID, movedTaskID)
case sourceLane == depsLaneBlocks && targetLane == depsLaneAll:
updates = append(updates, update{movedTaskID, depsAction(plugin.ActionOperatorRemove, contextTaskID)})
query = fmt.Sprintf(`update where id = "%s" set dependsOn=dependsOn-["%s"]`, movedTaskID, contextTaskID)
case sourceLane == depsLaneDepends && targetLane == depsLaneAll:
updates = append(updates, update{contextTaskID, depsAction(plugin.ActionOperatorRemove, movedTaskID)})
query = fmt.Sprintf(`update where id = "%s" set dependsOn=dependsOn-["%s"]`, contextTaskID, movedTaskID)
default:
return false
}
for _, u := range updates {
taskItem := dc.taskStore.GetTask(u.taskID)
if taskItem == nil {
slog.Error("deps move: task not found", "task_id", u.taskID)
return false
}
updated, err := plugin.ApplyLaneAction(taskItem, u.action, "")
if err != nil {
slog.Error("deps move: failed to apply action", "task_id", u.taskID, "error", err)
return false
}
parser := ruki.NewParser(dc.schema)
stmt, err := parser.ParseAndValidateStatement(query, ruki.ExecutorRuntimePlugin)
if err != nil {
slog.Error("deps move: failed to parse ruki query", "query", query, "error", err)
return false
}
executor := dc.newExecutor()
result, err := executor.Execute(stmt, dc.taskStore.GetAllTasks())
if err != nil {
slog.Error("deps move: failed to execute ruki query", "query", query, "error", err)
return false
}
if result.Update == nil || len(result.Update.Updated) == 0 {
return false
}
for _, updated := range result.Update.Updated {
if err := dc.mutationGate.UpdateTask(context.Background(), updated); err != nil {
slog.Error("deps move: failed to update task", "task_id", u.taskID, "error", err)
slog.Error("deps move: failed to update task", "task_id", updated.ID, "error", err)
if dc.statusline != nil {
dc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
@ -208,17 +215,6 @@ func (dc *DepsController) handleMoveTask(offset int) bool {
return true
}
// depsAction builds a LaneAction that adds or removes a single task ID from dependsOn.
func depsAction(op plugin.ActionOperator, taskID string) plugin.LaneAction {
return plugin.LaneAction{
Ops: []plugin.LaneActionOp{{
Field: plugin.ActionFieldDependsOn,
Operator: op,
DependsOn: []string{taskID},
}},
}
}
// resolveDependsTasks looks up full task objects for the context task's DependsOn IDs.
func (dc *DepsController) resolveDependsTasks(contextTask *task.Task, allTasks []*task.Task) []*task.Task {
if len(contextTask.DependsOn) == 0 {

View file

@ -5,6 +5,7 @@ import (
"slices"
"testing"
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/service"
@ -52,7 +53,7 @@ func newDepsTestEnv(t *testing.T) (*DepsController, store.Store) {
gate.SetStore(taskStore)
nav := newMockNavigationController()
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, nil)
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, nil, rukiRuntime.NewSchema())
return dc, taskStore
}
@ -467,7 +468,7 @@ func TestDepsController_DeleteTask_GateError(t *testing.T) {
nav := newMockNavigationController()
statusline := model.NewStatuslineConfig()
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, statusline)
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, statusline, rukiRuntime.NewSchema())
// select free task in All lane
dc.pluginConfig.SetSelectedLane(depsLaneAll)
@ -512,7 +513,7 @@ func TestDepsController_MoveTask_UpdateError(t *testing.T) {
nav := newMockNavigationController()
statusline := model.NewStatuslineConfig()
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, statusline)
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, statusline, rukiRuntime.NewSchema())
// select free task in All lane, move left → Blocks
dc.pluginConfig.SetSelectedLane(depsLaneAll)
@ -626,7 +627,7 @@ func newDepsNavEnv(t *testing.T, blockers int, allTasks int, depends int, laneCo
gate.SetStore(taskStore)
nav := newMockNavigationController()
return NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, nil)
return NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, nil, rukiRuntime.NewSchema())
}
func TestDepsController_NavRightAdjacentNonEmptyPreservesRow(t *testing.T) {

View file

@ -8,6 +8,7 @@ import (
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/service"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/task"
@ -51,6 +52,7 @@ type InputRouter struct {
taskStore store.Store
mutationGate *service.TaskMutationGate
statusline *model.StatuslineConfig
schema ruki.Schema
registerPlugin func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl PluginControllerInterface)
}
@ -62,6 +64,7 @@ func NewInputRouter(
taskStore store.Store,
mutationGate *service.TaskMutationGate,
statusline *model.StatuslineConfig,
schema ruki.Schema,
) *InputRouter {
return &InputRouter{
navController: navController,
@ -72,6 +75,7 @@ func NewInputRouter(
taskStore: taskStore,
mutationGate: mutationGate,
statusline: statusline,
schema: schema,
}
}
@ -244,7 +248,7 @@ func (ir *InputRouter) openDepsEditor(taskID string) bool {
pluginConfig.SetViewMode(vm)
}
ctrl := NewDepsController(ir.taskStore, ir.mutationGate, pluginConfig, pluginDef, ir.navController, ir.statusline)
ctrl := NewDepsController(ir.taskStore, ir.mutationGate, pluginConfig, pluginDef, ir.navController, ir.statusline, ir.schema)
if ir.registerPlugin != nil {
ir.registerPlugin(name, pluginConfig, pluginDef, ctrl)

View file

@ -4,12 +4,12 @@ import (
"context"
"log/slog"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/service"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/task"
@ -28,6 +28,7 @@ func NewPluginController(
pluginDef *plugin.TikiPlugin,
navController *NavigationController,
statusline *model.StatuslineConfig,
schema ruki.Schema,
) *PluginController {
pc := &PluginController{
pluginBase: pluginBase{
@ -38,6 +39,7 @@ func NewPluginController(
navController: navController,
statusline: statusline,
registry: PluginViewActions(),
schema: schema,
},
}
@ -151,32 +153,67 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
return false
}
executor := pc.newExecutor()
allTasks := pc.taskStore.GetAllTasks()
input := ruki.ExecutionInput{}
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
if taskID == "" {
return false
}
taskItem := pc.taskStore.GetTask(taskID)
if taskItem == nil {
return false
}
currentUser := getCurrentUserName(pc.taskStore)
updated, err := plugin.ApplyLaneAction(taskItem, pa.Action, currentUser)
if err != nil {
slog.Error("failed to apply plugin action", "task_id", taskID, "key", string(r), "error", err)
return false
}
if err := pc.mutationGate.UpdateTask(context.Background(), updated); err != nil {
slog.Error("failed to update task after plugin action", "task_id", taskID, "key", string(r), "error", err)
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
if pa.Action.IsUpdate() || pa.Action.IsDelete() {
if taskID == "" {
return false
}
input.SelectedTaskID = taskID
}
if pa.Action.IsCreate() {
template, err := pc.taskStore.NewTaskTemplate()
if err != nil {
slog.Error("failed to create task template for plugin action", "key", string(r), "error", err)
return false
}
input.CreateTemplate = template
}
result, err := executor.Execute(pa.Action, allTasks, input)
if err != nil {
slog.Error("failed to execute plugin action", "task_id", taskID, "key", string(r), "error", err)
return false
}
pc.ensureSearchResultIncludesTask(updated)
ctx := context.Background()
switch {
case result.Update != nil:
for _, updated := range result.Update.Updated {
if err := pc.mutationGate.UpdateTask(ctx, updated); err != nil {
slog.Error("failed to update task after plugin action", "task_id", updated.ID, "key", string(r), "error", err)
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
return false
}
pc.ensureSearchResultIncludesTask(updated)
}
case result.Create != nil:
if err := pc.mutationGate.CreateTask(ctx, result.Create.Task); err != nil {
slog.Error("failed to create task from plugin action", "key", string(r), "error", err)
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
return false
}
case result.Delete != nil:
for _, deleted := range result.Delete.Deleted {
if err := pc.mutationGate.DeleteTask(ctx, deleted); err != nil {
slog.Error("failed to delete task from plugin action", "task_id", deleted.ID, "key", string(r), "error", err)
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
return false
}
}
}
slog.Info("plugin action applied", "task_id", taskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name)
return true
}
@ -197,18 +234,24 @@ func (pc *PluginController) handleMoveTask(offset int) bool {
return false
}
taskItem := pc.taskStore.GetTask(taskID)
if taskItem == nil {
actionStmt := pc.pluginDef.Lanes[targetLane].Action
if actionStmt == nil {
return false
}
currentUser := getCurrentUserName(pc.taskStore)
updated, err := plugin.ApplyLaneAction(taskItem, pc.pluginDef.Lanes[targetLane].Action, currentUser)
allTasks := pc.taskStore.GetAllTasks()
executor := pc.newExecutor()
result, err := executor.Execute(actionStmt, allTasks, ruki.ExecutionInput{SelectedTaskID: taskID})
if err != nil {
slog.Error("failed to apply lane action", "task_id", taskID, "error", err)
slog.Error("failed to execute lane action", "task_id", taskID, "error", err)
return false
}
if result.Update == nil || len(result.Update.Updated) == 0 {
return false
}
updated := result.Update.Updated[0]
if err := pc.mutationGate.UpdateTask(context.Background(), updated); err != nil {
slog.Error("failed to update task after lane move", "task_id", taskID, "error", err)
if pc.statusline != nil {
@ -231,22 +274,24 @@ func (pc *PluginController) GetFilteredTasksForLane(lane int) []*task.Task {
return nil
}
// check if search is active
searchResults := pc.pluginConfig.GetSearchResults()
filterStmt := pc.pluginDef.Lanes[lane].Filter
allTasks := pc.taskStore.GetAllTasks()
now := time.Now()
currentUser := getCurrentUserName(pc.taskStore)
var filtered []*task.Task
for _, task := range allTasks {
laneFilter := pc.pluginDef.Lanes[lane].Filter
if laneFilter == nil || laneFilter.Evaluate(task, now, currentUser) {
filtered = append(filtered, task)
if filterStmt == nil {
filtered = allTasks
} else {
executor := pc.newExecutor()
result, err := executor.Execute(filterStmt, allTasks)
if err != nil {
slog.Error("failed to execute lane filter", "lane", lane, "error", err)
return nil
}
filtered = result.Select.Tasks
}
if searchResults != nil {
// narrow by search results if active
if searchResults := pc.pluginConfig.GetSearchResults(); searchResults != nil {
searchTaskMap := make(map[string]bool, len(searchResults))
for _, result := range searchResults {
searchTaskMap[result.Task.ID] = true
@ -254,10 +299,6 @@ func (pc *PluginController) GetFilteredTasksForLane(lane int) []*task.Task {
filtered = filterTasksBySearch(filtered, searchTaskMap)
}
if len(pc.pluginDef.Sort) > 0 {
plugin.SortTasks(filtered, pc.pluginDef.Sort)
}
return filtered
}

View file

@ -7,6 +7,7 @@ import (
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/service"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/task"
@ -22,6 +23,14 @@ type pluginBase struct {
navController *NavigationController
statusline *model.StatuslineConfig
registry *ActionRegistry
schema ruki.Schema
}
// newExecutor creates a ruki executor configured for plugin runtime.
func (pb *pluginBase) newExecutor() *ruki.Executor {
userName := getCurrentUserName(pb.taskStore)
return ruki.NewExecutor(pb.schema, func() string { return userName },
ruki.ExecutorRuntime{Mode: ruki.ExecutorRuntimePlugin})
}
func (pb *pluginBase) GetActionRegistry() *ActionRegistry { return pb.registry }

View file

@ -4,14 +4,28 @@ import (
"fmt"
"testing"
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/plugin/filter"
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/service"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/task"
)
// mustParseStmt is a test helper that parses and validates a ruki statement,
// failing the test on error.
func mustParseStmt(t *testing.T, input string) *ruki.ValidatedStatement {
t.Helper()
schema := rukiRuntime.NewSchema()
parser := ruki.NewParser(schema)
stmt, err := parser.ParseAndValidateStatement(input, ruki.ExecutorRuntimePlugin)
if err != nil {
t.Fatalf("parse ruki statement %q: %v", input, err)
}
return stmt
}
type navHarness struct {
pb *pluginBase
config *model.PluginConfig
@ -79,14 +93,8 @@ func TestEnsureFirstNonEmptyLaneSelectionSelectsFirstTask(t *testing.T) {
t.Fatalf("create task: %v", err)
}
emptyFilter, err := filter.ParseFilter("status = 'done'")
if err != nil {
t.Fatalf("parse filter: %v", err)
}
todoFilter, err := filter.ParseFilter("status = 'ready'")
if err != nil {
t.Fatalf("parse filter: %v", err)
}
emptyFilter := mustParseStmt(t, `select where status = "done"`)
todoFilter := mustParseStmt(t, `select where status = "ready"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{
@ -102,9 +110,10 @@ func TestEnsureFirstNonEmptyLaneSelectionSelectsFirstTask(t *testing.T) {
pluginConfig.SetSelectedLane(0)
pluginConfig.SetSelectedIndexForLane(0, 1)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
pc.EnsureFirstNonEmptyLaneSelection()
if pluginConfig.GetSelectedLane() != 1 {
@ -126,10 +135,7 @@ func TestEnsureFirstNonEmptyLaneSelectionKeepsCurrentLane(t *testing.T) {
t.Fatalf("create task: %v", err)
}
todoFilter, err := filter.ParseFilter("status = 'ready'")
if err != nil {
t.Fatalf("parse filter: %v", err)
}
todoFilter := mustParseStmt(t, `select where status = "ready"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{
@ -145,9 +151,10 @@ func TestEnsureFirstNonEmptyLaneSelectionKeepsCurrentLane(t *testing.T) {
pluginConfig.SetSelectedLane(1)
pluginConfig.SetSelectedIndexForLane(1, 0)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
pc.EnsureFirstNonEmptyLaneSelection()
if pluginConfig.GetSelectedLane() != 1 {
@ -160,10 +167,7 @@ func TestEnsureFirstNonEmptyLaneSelectionKeepsCurrentLane(t *testing.T) {
func TestEnsureFirstNonEmptyLaneSelectionNoTasks(t *testing.T) {
taskStore := store.NewInMemoryStore()
emptyFilter, err := filter.ParseFilter("status = 'done'")
if err != nil {
t.Fatalf("parse filter: %v", err)
}
emptyFilter := mustParseStmt(t, `select where status = "done"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{
@ -179,9 +183,10 @@ func TestEnsureFirstNonEmptyLaneSelectionNoTasks(t *testing.T) {
pluginConfig.SetSelectedLane(1)
pluginConfig.SetSelectedIndexForLane(1, 2)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
pc.EnsureFirstNonEmptyLaneSelection()
if pluginConfig.GetSelectedLane() != 1 {
@ -517,7 +522,7 @@ func TestPluginController_HandleOpenTask(t *testing.T) {
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory,
})
todoFilter, _ := filter.ParseFilter("status = 'ready'")
todoFilter := mustParseStmt(t, `select where status = "ready"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Todo", Columns: 1, Filter: todoFilter}},
@ -528,9 +533,10 @@ func TestPluginController_HandleOpenTask(t *testing.T) {
pluginConfig.SetSelectedIndexForLane(0, 0)
navController := newMockNavigationController()
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, navController, nil)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, navController, nil, schema)
if !pc.HandleAction(ActionOpenFromPlugin) {
t.Error("expected HandleAction(open) to return true when task is selected")
@ -544,7 +550,7 @@ func TestPluginController_HandleOpenTask(t *testing.T) {
func TestPluginController_HandleOpenTask_Empty(t *testing.T) {
taskStore := store.NewInMemoryStore()
emptyFilter, _ := filter.ParseFilter("status = 'done'")
emptyFilter := mustParseStmt(t, `select where status = "done"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Empty", Columns: 1, Filter: emptyFilter}},
@ -552,9 +558,10 @@ func TestPluginController_HandleOpenTask_Empty(t *testing.T) {
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil, schema)
if pc.HandleAction(ActionOpenFromPlugin) {
t.Error("expected false when no task is selected")
@ -567,7 +574,7 @@ func TestPluginController_HandleDeleteTask(t *testing.T) {
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
})
todoFilter, _ := filter.ParseFilter("status = 'ready'")
todoFilter := mustParseStmt(t, `select where status = "ready"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Todo", Columns: 1, Filter: todoFilter}},
@ -577,9 +584,10 @@ func TestPluginController_HandleDeleteTask(t *testing.T) {
pluginConfig.SetSelectedLane(0)
pluginConfig.SetSelectedIndexForLane(0, 0)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil, schema)
if !pc.HandleAction(ActionDeleteTask) {
t.Error("expected HandleAction(delete) to return true")
@ -592,7 +600,7 @@ func TestPluginController_HandleDeleteTask(t *testing.T) {
func TestPluginController_HandleDeleteTask_Empty(t *testing.T) {
taskStore := store.NewInMemoryStore()
emptyFilter, _ := filter.ParseFilter("status = 'done'")
emptyFilter := mustParseStmt(t, `select where status = "done"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Empty", Columns: 1, Filter: emptyFilter}},
@ -600,9 +608,10 @@ func TestPluginController_HandleDeleteTask_Empty(t *testing.T) {
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil, schema)
if pc.HandleAction(ActionDeleteTask) {
t.Error("expected false when no task is selected")
@ -615,7 +624,7 @@ func TestPluginController_HandleDeleteTask_Rejected(t *testing.T) {
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
})
todoFilter, _ := filter.ParseFilter("status = 'ready'")
todoFilter := mustParseStmt(t, `select where status = "ready"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Todo", Columns: 1, Filter: todoFilter}},
@ -626,12 +635,13 @@ func TestPluginController_HandleDeleteTask_Rejected(t *testing.T) {
pluginConfig.SetSelectedIndexForLane(0, 0)
statusline := model.NewStatuslineConfig()
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
gate.OnDelete(func(_, _ *task.Task, _ []*task.Task) *service.Rejection {
return &service.Rejection{Reason: "cannot delete"}
})
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), statusline)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), statusline, schema)
if pc.HandleAction(ActionDeleteTask) {
t.Error("expected false when delete is rejected")
@ -645,7 +655,7 @@ func TestPluginController_HandleDeleteTask_Rejected(t *testing.T) {
func TestPluginController_GetNameAndRegistry(t *testing.T) {
taskStore := store.NewInMemoryStore()
todoFilter, _ := filter.ParseFilter("status = 'ready'")
todoFilter := mustParseStmt(t, `select where status = "ready"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "MyPlugin"},
Lanes: []plugin.TikiLane{{Name: "Todo", Columns: 1, Filter: todoFilter}},
@ -653,9 +663,10 @@ func TestPluginController_GetNameAndRegistry(t *testing.T) {
pluginConfig := model.NewPluginConfig("MyPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
if pc.GetPluginName() != "MyPlugin" {
t.Errorf("GetPluginName() = %q, want %q", pc.GetPluginName(), "MyPlugin")
@ -671,20 +682,15 @@ func TestPluginController_HandleMoveTask_Rejected(t *testing.T) {
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
})
readyFilter, _ := filter.ParseFilter("status = 'ready'")
inProgressFilter, _ := filter.ParseFilter("status = 'in_progress'")
readyFilter := mustParseStmt(t, `select where status = "ready"`)
inProgressFilter := mustParseStmt(t, `select where status = "inProgress"`)
inProgressAction := mustParseStmt(t, `update where id = id() set status = "inProgress"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{
{Name: "Ready", Columns: 1, Filter: readyFilter},
{
Name: "InProgress", Columns: 1, Filter: inProgressFilter,
Action: plugin.LaneAction{
Ops: []plugin.LaneActionOp{
{Field: plugin.ActionFieldStatus, Operator: plugin.ActionOperatorAssign, StrValue: "inProgress"},
},
},
},
{Name: "InProgress", Columns: 1, Filter: inProgressFilter, Action: inProgressAction},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
@ -693,12 +699,13 @@ func TestPluginController_HandleMoveTask_Rejected(t *testing.T) {
pluginConfig.SetSelectedIndexForLane(0, 0)
statusline := model.NewStatuslineConfig()
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
gate.OnUpdate(func(_, _ *task.Task, _ []*task.Task) *service.Rejection {
return &service.Rejection{Reason: "updates blocked"}
})
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline, schema)
if pc.HandleAction(ActionMoveTaskRight) {
t.Error("expected false when move is rejected by gate")
@ -717,19 +724,17 @@ func TestPluginController_HandlePluginAction_Success(t *testing.T) {
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
})
readyFilter, _ := filter.ParseFilter("status = 'ready'")
readyFilter := mustParseStmt(t, `select where status = "ready"`)
markDoneAction := mustParseStmt(t, `update where id = id() set status = "done"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
Actions: []plugin.PluginAction{
{
Rune: 'd',
Label: "Mark Done",
Action: plugin.LaneAction{
Ops: []plugin.LaneActionOp{
{Field: plugin.ActionFieldStatus, Operator: plugin.ActionOperatorAssign, StrValue: "done"},
},
},
Rune: 'd',
Label: "Mark Done",
Action: markDoneAction,
},
},
}
@ -738,9 +743,10 @@ func TestPluginController_HandlePluginAction_Success(t *testing.T) {
pluginConfig.SetSelectedLane(0)
pluginConfig.SetSelectedIndexForLane(0, 0)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
if !pc.HandleAction(pluginActionID('d')) {
t.Error("expected true for successful plugin action")
@ -758,19 +764,17 @@ func TestPluginController_HandlePluginAction_Rejected(t *testing.T) {
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
})
readyFilter, _ := filter.ParseFilter("status = 'ready'")
readyFilter := mustParseStmt(t, `select where status = "ready"`)
markDoneAction := mustParseStmt(t, `update where id = id() set status = "done"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
Actions: []plugin.PluginAction{
{
Rune: 'd',
Label: "Mark Done",
Action: plugin.LaneAction{
Ops: []plugin.LaneActionOp{
{Field: plugin.ActionFieldStatus, Operator: plugin.ActionOperatorAssign, StrValue: "done"},
},
},
Rune: 'd',
Label: "Mark Done",
Action: markDoneAction,
},
},
}
@ -780,12 +784,13 @@ func TestPluginController_HandlePluginAction_Rejected(t *testing.T) {
pluginConfig.SetSelectedIndexForLane(0, 0)
statusline := model.NewStatuslineConfig()
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
gate.OnUpdate(func(_, _ *task.Task, _ []*task.Task) *service.Rejection {
return &service.Rejection{Reason: "updates blocked"}
})
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline, schema)
if pc.HandleAction(pluginActionID('d')) {
t.Error("expected false when plugin action is rejected by gate")

View file

@ -21,12 +21,12 @@ func TestPluginView_MoveTaskAppliesLaneAction(t *testing.T) {
lanes:
- name: Backlog
columns: 1
filter: status = 'backlog'
action: status=backlog, tags-=[moved]
filter: select where status = "backlog"
action: update where id = id() set status="backlog" tags=tags-["moved"]
- name: Done
columns: 1
filter: status = 'done'
action: status=done, tags+=[moved]
filter: select where status = "done"
action: update where id = id() set status="done" tags=tags+["moved"]
`
if err := os.WriteFile(filepath.Join(tmpDir, "workflow.yaml"), []byte(workflowContent), 0644); err != nil {
t.Fatalf("failed to write workflow.yaml: %v", err)

View file

@ -6,6 +6,7 @@ import (
"github.com/boolean-maybe/tiki/controller"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/service"
"github.com/boolean-maybe/tiki/store"
)
@ -25,6 +26,7 @@ func BuildControllers(
plugins []plugin.Plugin,
pluginConfigs map[string]*model.PluginConfig,
statuslineConfig *model.StatuslineConfig,
schema ruki.Schema,
) *Controllers {
navController := controller.NewNavigationController(app)
taskController := controller.NewTaskController(taskStore, mutationGate, navController, statuslineConfig)
@ -39,6 +41,7 @@ func BuildControllers(
tp,
navController,
statuslineConfig,
schema,
)
continue
}

View file

@ -107,8 +107,11 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
headerConfig, layoutModel := InitHeaderAndLayoutModels()
statuslineConfig := InitStatuslineModel(tikiStore)
// Phase 5.5: Ruki schema (needed by plugin parser and trigger system)
schema := rukiRuntime.NewSchema()
// Phase 6: Plugin system
plugins, err := LoadPlugins()
plugins, err := LoadPlugins(schema)
if err != nil {
return nil, err
}
@ -117,7 +120,6 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
pluginConfigs, pluginDefs := BuildPluginConfigsAndDefs(plugins)
// Phase 6.5: Trigger system
schema := rukiRuntime.NewSchema()
userName, _, _ := taskStore.GetCurrentUser()
triggerEngine, triggerCount, err := service.LoadAndRegisterTriggers(gate, schema, func() string { return userName })
if err != nil {
@ -138,6 +140,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
plugins,
pluginConfigs,
statuslineConfig,
schema,
)
// Phase 8: Input routing
@ -148,6 +151,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
taskStore,
gate,
statuslineConfig,
schema,
)
// Phase 9: View factory and layout

View file

@ -6,12 +6,13 @@ import (
"github.com/boolean-maybe/tiki/controller"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/ruki"
)
// LoadPlugins loads plugins from disk. Returns an error if workflow files
// exist but contain no valid view definitions.
func LoadPlugins() ([]plugin.Plugin, error) {
plugins, err := plugin.LoadPlugins()
func LoadPlugins(schema ruki.Schema) ([]plugin.Plugin, error) {
plugins, err := plugin.LoadPlugins(schema)
if err != nil {
return nil, err
}

View file

@ -1,478 +0,0 @@
package plugin
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/boolean-maybe/tiki/task"
)
// LaneAction represents parsed lane actions.
type LaneAction struct {
Ops []LaneActionOp
}
// LaneActionOp represents a single action operation.
type LaneActionOp struct {
Field ActionField
Operator ActionOperator
StrValue string
IntValue int
Tags []string
DependsOn []string
DueValue time.Time
RecurrenceValue task.Recurrence
}
// ActionField identifies a supported action field.
type ActionField string
const (
ActionFieldStatus ActionField = "status"
ActionFieldType ActionField = "type"
ActionFieldPriority ActionField = "priority"
ActionFieldAssignee ActionField = "assignee"
ActionFieldPoints ActionField = "points"
ActionFieldTags ActionField = "tags"
ActionFieldDependsOn ActionField = "dependsOn"
ActionFieldDue ActionField = "due"
ActionFieldRecurrence ActionField = "recurrence"
)
// ActionOperator identifies a supported action operator.
type ActionOperator string
const (
ActionOperatorAssign ActionOperator = "="
ActionOperatorAdd ActionOperator = "+="
ActionOperatorRemove ActionOperator = "-="
)
// ParseLaneAction parses a lane action string into operations.
func ParseLaneAction(input string) (LaneAction, error) {
input = strings.TrimSpace(input)
if input == "" {
return LaneAction{}, nil
}
parts, err := splitTopLevelCommas(input)
if err != nil {
return LaneAction{}, err
}
ops := make([]LaneActionOp, 0, len(parts))
for _, part := range parts {
if part == "" {
return LaneAction{}, fmt.Errorf("empty action segment")
}
field, op, value, err := parseActionSegment(part)
if err != nil {
return LaneAction{}, err
}
switch field {
case ActionFieldTags:
if op == ActionOperatorAssign {
return LaneAction{}, fmt.Errorf("tags action only supports += or -=")
}
tags, err := parseTagsValue(value)
if err != nil {
return LaneAction{}, err
}
ops = append(ops, LaneActionOp{
Field: field,
Operator: op,
Tags: tags,
})
case ActionFieldDependsOn:
if op == ActionOperatorAssign {
return LaneAction{}, fmt.Errorf("dependsOn action only supports += or -=")
}
deps, err := parseTagsValue(value)
if err != nil {
return LaneAction{}, err
}
ops = append(ops, LaneActionOp{
Field: field,
Operator: op,
DependsOn: deps,
})
case ActionFieldPriority, ActionFieldPoints:
if op != ActionOperatorAssign {
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
}
intValue, err := parseIntValue(value)
if err != nil {
return LaneAction{}, err
}
if field == ActionFieldPriority && !task.IsValidPriority(intValue) {
return LaneAction{}, fmt.Errorf("priority value out of range: %d", intValue)
}
if field == ActionFieldPoints && !task.IsValidPoints(intValue) {
return LaneAction{}, fmt.Errorf("points value out of range: %d", intValue)
}
ops = append(ops, LaneActionOp{
Field: field,
Operator: op,
IntValue: intValue,
})
case ActionFieldStatus:
if op != ActionOperatorAssign {
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
}
strValue, err := parseStringValue(value)
if err != nil {
return LaneAction{}, err
}
if _, ok := task.ParseStatus(strValue); !ok {
return LaneAction{}, fmt.Errorf("invalid status value %q", strValue)
}
ops = append(ops, LaneActionOp{
Field: field,
Operator: op,
StrValue: strValue,
})
case ActionFieldType:
if op != ActionOperatorAssign {
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
}
strValue, err := parseStringValue(value)
if err != nil {
return LaneAction{}, err
}
if _, ok := task.ParseType(strValue); !ok {
return LaneAction{}, fmt.Errorf("invalid type value %q", strValue)
}
ops = append(ops, LaneActionOp{
Field: field,
Operator: op,
StrValue: strValue,
})
case ActionFieldDue:
if op != ActionOperatorAssign {
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
}
dueValue, err := parseDateValue(value)
if err != nil {
return LaneAction{}, err
}
ops = append(ops, LaneActionOp{
Field: field,
Operator: op,
DueValue: dueValue,
})
case ActionFieldRecurrence:
if op != ActionOperatorAssign {
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
}
recValue, err := parseRecurrenceValue(value)
if err != nil {
return LaneAction{}, err
}
ops = append(ops, LaneActionOp{
Field: field,
Operator: op,
RecurrenceValue: recValue,
})
default:
if op != ActionOperatorAssign {
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
}
strValue, err := parseStringValue(value)
if err != nil {
return LaneAction{}, err
}
ops = append(ops, LaneActionOp{
Field: field,
Operator: op,
StrValue: strValue,
})
}
}
return LaneAction{Ops: ops}, nil
}
// ApplyLaneAction applies a parsed action to a task clone.
func ApplyLaneAction(src *task.Task, action LaneAction, currentUser string) (*task.Task, error) {
if src == nil {
return nil, fmt.Errorf("task is nil")
}
if len(action.Ops) == 0 {
return src.Clone(), nil
}
clone := src.Clone()
for _, op := range action.Ops {
switch op.Field {
case ActionFieldStatus:
clone.Status = task.MapStatus(op.StrValue)
case ActionFieldType:
clone.Type = task.NormalizeType(op.StrValue)
case ActionFieldPriority:
clone.Priority = op.IntValue
case ActionFieldAssignee:
assignee := op.StrValue
if isCurrentUserToken(assignee) {
if strings.TrimSpace(currentUser) == "" {
return nil, fmt.Errorf("current user is not available for assignee")
}
assignee = currentUser
}
clone.Assignee = assignee
case ActionFieldPoints:
clone.Points = op.IntValue
case ActionFieldTags:
clone.Tags = applyTagOperation(clone.Tags, op.Operator, op.Tags)
case ActionFieldDependsOn:
clone.DependsOn = applyTagOperation(clone.DependsOn, op.Operator, op.DependsOn)
case ActionFieldDue:
clone.Due = op.DueValue
case ActionFieldRecurrence:
clone.Recurrence = op.RecurrenceValue
default:
return nil, fmt.Errorf("unsupported action field %q", op.Field)
}
}
return clone, nil
}
func isCurrentUserToken(value string) bool {
return strings.EqualFold(strings.TrimSpace(value), "CURRENT_USER")
}
func parseActionSegment(segment string) (ActionField, ActionOperator, string, error) {
opIdx, op := findOperator(segment)
if opIdx == -1 {
return "", "", "", fmt.Errorf("action segment missing operator: %q", segment)
}
field := strings.TrimSpace(segment[:opIdx])
value := strings.TrimSpace(segment[opIdx+len(op):])
if field == "" || value == "" {
return "", "", "", fmt.Errorf("invalid action segment: %q", segment)
}
switch strings.ToLower(field) {
case "status":
return ActionFieldStatus, op, value, nil
case "type":
return ActionFieldType, op, value, nil
case "priority":
return ActionFieldPriority, op, value, nil
case "assignee":
return ActionFieldAssignee, op, value, nil
case "points":
return ActionFieldPoints, op, value, nil
case "tags":
return ActionFieldTags, op, value, nil
case "dependson":
return ActionFieldDependsOn, op, value, nil
case "due":
return ActionFieldDue, op, value, nil
case "recurrence":
return ActionFieldRecurrence, op, value, nil
default:
return "", "", "", fmt.Errorf("unknown action field %q", field)
}
}
func findOperator(segment string) (int, ActionOperator) {
if idx := strings.Index(segment, "+="); idx != -1 {
return idx, ActionOperatorAdd
}
if idx := strings.Index(segment, "-="); idx != -1 {
return idx, ActionOperatorRemove
}
if idx := strings.Index(segment, "="); idx != -1 {
return idx, ActionOperatorAssign
}
return -1, ""
}
// unquote trims whitespace and strips surrounding single or double quotes.
func unquote(raw string) string {
v := strings.TrimSpace(raw)
if len(v) >= 2 {
if (v[0] == '\'' && v[len(v)-1] == '\'') ||
(v[0] == '"' && v[len(v)-1] == '"') {
v = v[1 : len(v)-1]
}
}
return strings.TrimSpace(v)
}
func parseStringValue(raw string) (string, error) {
value := unquote(raw)
if value == "" {
return "", fmt.Errorf("string value is empty")
}
return value, nil
}
// parseDateValue parses a date value for the due field.
// Empty string (or empty after unquoting) means clear the due date (returns zero time).
// Otherwise parses as YYYY-MM-DD format.
func parseDateValue(raw string) (time.Time, error) {
value := unquote(raw)
// Empty string means clear the due date
if value == "" {
return time.Time{}, nil
}
// Parse as date
parsed, ok := task.ParseDueDate(value)
if !ok {
return time.Time{}, fmt.Errorf("invalid date format %q (expected YYYY-MM-DD)", value)
}
return parsed, nil
}
// parseRecurrenceValue parses a recurrence value from an action expression.
// Empty string (or empty after unquoting) clears the recurrence.
// Otherwise parses as a cron pattern or display name.
func parseRecurrenceValue(raw string) (task.Recurrence, error) {
value := unquote(raw)
if value == "" {
return task.RecurrenceNone, nil
}
// try as cron pattern first
if r, ok := task.ParseRecurrence(value); ok {
return r, nil
}
// try as display name
r := task.RecurrenceFromDisplay(value)
if r != task.RecurrenceNone {
return r, nil
}
return task.RecurrenceNone, fmt.Errorf("invalid recurrence value %q", value)
}
func parseIntValue(raw string) (int, error) {
value := strings.TrimSpace(raw)
intValue, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("invalid integer value %q", value)
}
return intValue, nil
}
func parseTagsValue(raw string) ([]string, error) {
value := strings.TrimSpace(raw)
if !strings.HasPrefix(value, "[") || !strings.HasSuffix(value, "]") {
return nil, fmt.Errorf("tags value must be in brackets, got %q", value)
}
inner := strings.TrimSpace(value[1 : len(value)-1])
if inner == "" {
return nil, fmt.Errorf("tags list is empty")
}
parts, err := splitTopLevelCommas(inner)
if err != nil {
return nil, err
}
tags := make([]string, 0, len(parts))
for _, part := range parts {
tag, err := parseStringValue(part)
if err != nil {
return nil, err
}
tags = append(tags, tag)
}
return tags, nil
}
func splitTopLevelCommas(input string) ([]string, error) {
var parts []string
start := 0
inSingle := false
inDouble := false
bracketDepth := 0
for i, r := range input {
switch r {
case '\'':
if !inDouble {
inSingle = !inSingle
}
case '"':
if !inSingle {
inDouble = !inDouble
}
case '[':
if !inSingle && !inDouble {
bracketDepth++
}
case ']':
if !inSingle && !inDouble {
if bracketDepth == 0 {
return nil, fmt.Errorf("unexpected ']' in %q", input)
}
bracketDepth--
}
case ',':
if !inSingle && !inDouble && bracketDepth == 0 {
part := strings.TrimSpace(input[start:i])
parts = append(parts, part)
start = i + 1
}
}
}
if inSingle || inDouble || bracketDepth != 0 {
return nil, fmt.Errorf("unterminated quotes or brackets in %q", input)
}
part := strings.TrimSpace(input[start:])
parts = append(parts, part)
return parts, nil
}
func applyTagOperation(current []string, op ActionOperator, tags []string) []string {
switch op {
case ActionOperatorAdd:
return addTags(current, tags)
case ActionOperatorRemove:
return removeTags(current, tags)
default:
return current
}
}
func addTags(current []string, tags []string) []string {
existing := make(map[string]bool, len(current))
for _, tag := range current {
existing[tag] = true
}
for _, tag := range tags {
if !existing[tag] {
current = append(current, tag)
existing[tag] = true
}
}
return current
}
func removeTags(current []string, tags []string) []string {
toRemove := make(map[string]bool, len(tags))
for _, tag := range tags {
toRemove[tag] = true
}
filtered := current[:0]
for _, tag := range current {
if !toRemove[tag] {
filtered = append(filtered, tag)
}
}
return filtered
}

View file

@ -1,648 +0,0 @@
package plugin
import (
"reflect"
"strings"
"testing"
"time"
"github.com/boolean-maybe/tiki/task"
)
func TestSplitTopLevelCommas(t *testing.T) {
tests := []struct {
name string
input string
want []string
wantErr string
}{
{
name: "simple split",
input: "status=todo, type=bug",
want: []string{"status=todo", "type=bug"},
},
{
name: "comma in quotes",
input: "assignee='O,Brien', status=done",
want: []string{"assignee='O,Brien'", "status=done"},
},
{
name: "comma in brackets",
input: "tags+=[one,two], status=done",
want: []string{"tags+=[one,two]", "status=done"},
},
{
name: "mixed quotes and brackets",
input: `tags+=[one,"two,three"], status=done`,
want: []string{`tags+=[one,"two,three"]`, "status=done"},
},
{
name: "unterminated quote",
input: "status='todo, type=bug",
wantErr: "unterminated quotes or brackets",
},
{
name: "unterminated brackets",
input: "tags+=[one,two, status=done",
wantErr: "unterminated quotes or brackets",
},
{
name: "unexpected closing bracket",
input: "status=todo], type=bug",
wantErr: "unexpected ']'",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := splitTopLevelCommas(tc.input)
if tc.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("expected %v, got %v", tc.want, got)
}
})
}
}
func TestParseLaneAction(t *testing.T) {
action, err := ParseLaneAction("status=done, type=bug, priority=2, points=3, assignee='Alice', tags+=[frontend,'needs review'], dependsOn+=[TIKI-ABC123]")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(action.Ops) != 7 {
t.Fatalf("expected 7 ops, got %d", len(action.Ops))
}
gotFields := []ActionField{
action.Ops[0].Field,
action.Ops[1].Field,
action.Ops[2].Field,
action.Ops[3].Field,
action.Ops[4].Field,
action.Ops[5].Field,
action.Ops[6].Field,
}
wantFields := []ActionField{
ActionFieldStatus,
ActionFieldType,
ActionFieldPriority,
ActionFieldPoints,
ActionFieldAssignee,
ActionFieldTags,
ActionFieldDependsOn,
}
if !reflect.DeepEqual(gotFields, wantFields) {
t.Fatalf("expected fields %v, got %v", wantFields, gotFields)
}
if action.Ops[0].StrValue != "done" {
t.Fatalf("expected status value 'done', got %q", action.Ops[0].StrValue)
}
if action.Ops[1].StrValue != "bug" {
t.Fatalf("expected type value 'bug', got %q", action.Ops[1].StrValue)
}
if action.Ops[2].IntValue != 2 {
t.Fatalf("expected priority 2, got %d", action.Ops[2].IntValue)
}
if action.Ops[3].IntValue != 3 {
t.Fatalf("expected points 3, got %d", action.Ops[3].IntValue)
}
if action.Ops[4].StrValue != "Alice" {
t.Fatalf("expected assignee Alice, got %q", action.Ops[4].StrValue)
}
if !reflect.DeepEqual(action.Ops[5].Tags, []string{"frontend", "needs review"}) {
t.Fatalf("expected tags [frontend needs review], got %v", action.Ops[5].Tags)
}
if !reflect.DeepEqual(action.Ops[6].DependsOn, []string{"TIKI-ABC123"}) {
t.Fatalf("expected dependsOn [TIKI-ABC123], got %v", action.Ops[6].DependsOn)
}
}
func TestParseLaneAction_Errors(t *testing.T) {
tests := []struct {
name string
input string
wantErr string
}{
{
name: "empty segment",
input: "status=done,,type=bug",
wantErr: "empty action segment",
},
{
name: "missing operator",
input: "statusdone",
wantErr: "missing operator",
},
{
name: "tags assign not allowed",
input: "tags=[one]",
wantErr: "tags action only supports",
},
{
name: "status add not allowed",
input: "status+=done",
wantErr: "status action only supports",
},
{
name: "unknown field",
input: "owner=me",
wantErr: "unknown action field",
},
{
name: "invalid status",
input: "status=unknown",
wantErr: "invalid status value",
},
{
name: "invalid type",
input: "type=unknown",
wantErr: "invalid type value",
},
{
name: "priority out of range",
input: "priority=10",
wantErr: "priority value out of range",
},
{
name: "points out of range",
input: "points=-1",
wantErr: "points value out of range",
},
{
name: "tags missing brackets",
input: "tags+={one}",
wantErr: "tags value must be in brackets",
},
{
name: "dependsOn assign not allowed",
input: "dependsOn=[TIKI-ABC123]",
wantErr: "dependsOn action only supports",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := ParseLaneAction(tc.input)
if err == nil {
t.Fatalf("expected error containing %q", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
}
})
}
}
func TestApplyLaneAction(t *testing.T) {
base := &task.Task{
ID: "TASK-1",
Title: "Task",
Status: task.StatusBacklog,
Type: task.TypeStory,
Priority: task.PriorityMedium,
Points: 1,
Tags: []string{"existing"},
DependsOn: []string{"TIKI-AAA111"},
Assignee: "Bob",
}
action, err := ParseLaneAction("status=done, type=bug, priority=2, points=3, assignee=Alice, tags+=[moved], dependsOn+=[TIKI-BBB222]")
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
updated, err := ApplyLaneAction(base, action, "")
if err != nil {
t.Fatalf("unexpected apply error: %v", err)
}
if updated.Status != task.StatusDone {
t.Fatalf("expected status done, got %v", updated.Status)
}
if updated.Type != task.TypeBug {
t.Fatalf("expected type bug, got %v", updated.Type)
}
if updated.Priority != 2 {
t.Fatalf("expected priority 2, got %d", updated.Priority)
}
if updated.Points != 3 {
t.Fatalf("expected points 3, got %d", updated.Points)
}
if updated.Assignee != "Alice" {
t.Fatalf("expected assignee Alice, got %q", updated.Assignee)
}
if !reflect.DeepEqual(updated.Tags, []string{"existing", "moved"}) {
t.Fatalf("expected tags [existing moved], got %v", updated.Tags)
}
if !reflect.DeepEqual(updated.DependsOn, []string{"TIKI-AAA111", "TIKI-BBB222"}) {
t.Fatalf("expected dependsOn [TIKI-AAA111 TIKI-BBB222], got %v", updated.DependsOn)
}
if base.Status != task.StatusBacklog {
t.Fatalf("expected base task unchanged, got %v", base.Status)
}
if !reflect.DeepEqual(base.Tags, []string{"existing"}) {
t.Fatalf("expected base tags unchanged, got %v", base.Tags)
}
if !reflect.DeepEqual(base.DependsOn, []string{"TIKI-AAA111"}) {
t.Fatalf("expected base dependsOn unchanged, got %v", base.DependsOn)
}
}
func TestApplyLaneAction_InvalidResult(t *testing.T) {
// ApplyLaneAction no longer validates the result — the gate does that
// at persistence time. This test verifies the action is applied as-is.
base := &task.Task{
ID: "TASK-1",
Title: "Task",
Status: task.StatusBacklog,
Type: task.TypeStory,
Priority: task.PriorityMedium,
Points: 1,
}
action := LaneAction{
Ops: []LaneActionOp{
{
Field: ActionFieldPriority,
Operator: ActionOperatorAssign,
IntValue: 99,
},
},
}
result, err := ApplyLaneAction(base, action, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Priority != 99 {
t.Errorf("expected priority 99, got %d", result.Priority)
}
}
func TestApplyLaneAction_AssigneeCurrentUser(t *testing.T) {
base := &task.Task{
ID: "TASK-1",
Title: "Task",
Status: task.StatusBacklog,
Type: task.TypeStory,
Priority: task.PriorityMedium,
Points: 1,
Assignee: "Bob",
}
action, err := ParseLaneAction("assignee=CURRENT_USER")
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
updated, err := ApplyLaneAction(base, action, "Alex")
if err != nil {
t.Fatalf("unexpected apply error: %v", err)
}
if updated.Assignee != "Alex" {
t.Fatalf("expected assignee Alex, got %q", updated.Assignee)
}
}
func TestApplyLaneAction_AssigneeCurrentUserMissing(t *testing.T) {
base := &task.Task{
ID: "TASK-1",
Title: "Task",
Status: task.StatusBacklog,
Type: task.TypeStory,
Priority: task.PriorityMedium,
Points: 1,
}
action, err := ParseLaneAction("assignee=CURRENT_USER")
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
_, err = ApplyLaneAction(base, action, "")
if err == nil {
t.Fatalf("expected error for missing current user")
}
if !strings.Contains(err.Error(), "current user") {
t.Fatalf("expected current user error, got %v", err)
}
}
func TestParseLaneAction_Due(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
checkFunc func(*testing.T, LaneAction)
}{
{
name: "due with valid date",
input: "due=2026-03-16",
wantErr: false,
checkFunc: func(t *testing.T, action LaneAction) {
if len(action.Ops) != 1 {
t.Fatalf("expected 1 op, got %d", len(action.Ops))
}
op := action.Ops[0]
if op.Field != ActionFieldDue {
t.Errorf("expected field due, got %v", op.Field)
}
if op.Operator != ActionOperatorAssign {
t.Errorf("expected operator =, got %v", op.Operator)
}
expectedDate := "2026-03-16"
gotDate := op.DueValue.Format(task.DateFormat)
if gotDate != expectedDate {
t.Errorf("expected date %v, got %v", expectedDate, gotDate)
}
},
},
{
name: "due with quoted date",
input: "due='2026-03-16'",
wantErr: false,
checkFunc: func(t *testing.T, action LaneAction) {
if len(action.Ops) != 1 {
t.Fatalf("expected 1 op, got %d", len(action.Ops))
}
op := action.Ops[0]
expectedDate := "2026-03-16"
gotDate := op.DueValue.Format(task.DateFormat)
if gotDate != expectedDate {
t.Errorf("expected date %v, got %v", expectedDate, gotDate)
}
},
},
{
name: "due with empty string (clear)",
input: "due=''",
wantErr: false,
checkFunc: func(t *testing.T, action LaneAction) {
if len(action.Ops) != 1 {
t.Fatalf("expected 1 op, got %d", len(action.Ops))
}
op := action.Ops[0]
if !op.DueValue.IsZero() {
t.Errorf("expected zero time for empty string, got %v", op.DueValue)
}
},
},
{
name: "due with invalid date format",
input: "due=03/16/2026",
wantErr: true,
},
{
name: "due with += operator",
input: "due+=2026-03-16",
wantErr: true,
},
{
name: "due with -= operator",
input: "due-=2026-03-16",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
action, err := ParseLaneAction(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseLaneAction() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err == nil && tt.checkFunc != nil {
tt.checkFunc(t, action)
}
})
}
}
func TestParseLaneAction_Recurrence(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
checkFunc func(*testing.T, LaneAction)
}{
{
name: "recurrence with cron pattern",
input: "recurrence='0 0 * * MON'",
wantErr: false,
checkFunc: func(t *testing.T, action LaneAction) {
if len(action.Ops) != 1 {
t.Fatalf("expected 1 op, got %d", len(action.Ops))
}
op := action.Ops[0]
if op.Field != ActionFieldRecurrence {
t.Errorf("expected field recurrence, got %v", op.Field)
}
if op.RecurrenceValue != task.Recurrence("0 0 * * MON") {
t.Errorf("expected '0 0 * * MON', got %q", op.RecurrenceValue)
}
},
},
{
name: "recurrence with display name",
input: "recurrence=Daily",
wantErr: false,
checkFunc: func(t *testing.T, action LaneAction) {
op := action.Ops[0]
if op.RecurrenceValue != task.RecurrenceDaily {
t.Errorf("expected daily cron, got %q", op.RecurrenceValue)
}
},
},
{
name: "recurrence clear with empty string",
input: "recurrence=''",
wantErr: false,
checkFunc: func(t *testing.T, action LaneAction) {
op := action.Ops[0]
if op.RecurrenceValue != task.RecurrenceNone {
t.Errorf("expected empty recurrence, got %q", op.RecurrenceValue)
}
},
},
{
name: "recurrence invalid value",
input: "recurrence=biweekly",
wantErr: true,
},
{
name: "recurrence += not allowed",
input: "recurrence+=Daily",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
action, err := ParseLaneAction(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseLaneAction() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err == nil && tt.checkFunc != nil {
tt.checkFunc(t, action)
}
})
}
}
func TestApplyLaneAction_Recurrence(t *testing.T) {
base := &task.Task{
ID: "TIKI-TEST01",
Title: "Test Task",
Type: task.TypeStory,
Status: "backlog",
Priority: 3,
}
t.Run("set recurrence", func(t *testing.T) {
action, err := ParseLaneAction("recurrence='0 0 * * MON'")
if err != nil {
t.Fatalf("ParseLaneAction() error = %v", err)
}
result, err := ApplyLaneAction(base, action, "")
if err != nil {
t.Fatalf("ApplyLaneAction() error = %v", err)
}
if result.Recurrence != task.Recurrence("0 0 * * MON") {
t.Errorf("expected '0 0 * * MON', got %q", result.Recurrence)
}
})
t.Run("clear recurrence", func(t *testing.T) {
baseWithRec := base.Clone()
baseWithRec.Recurrence = "0 0 * * MON"
action, err := ParseLaneAction("recurrence=''")
if err != nil {
t.Fatalf("ParseLaneAction() error = %v", err)
}
result, err := ApplyLaneAction(baseWithRec, action, "")
if err != nil {
t.Fatalf("ApplyLaneAction() error = %v", err)
}
if result.Recurrence != task.RecurrenceNone {
t.Errorf("expected empty recurrence, got %q", result.Recurrence)
}
})
}
func TestApplyLaneAction_Due(t *testing.T) {
base := &task.Task{
ID: "TIKI-TEST01",
Title: "Test Task",
Type: task.TypeStory,
Status: "backlog",
Priority: 3,
}
t.Run("set due date", func(t *testing.T) {
action, err := ParseLaneAction("due=2026-03-16")
if err != nil {
t.Fatalf("ParseLaneAction() error = %v", err)
}
result, err := ApplyLaneAction(base, action, "")
if err != nil {
t.Fatalf("ApplyLaneAction() error = %v", err)
}
expectedDate := "2026-03-16"
gotDate := result.Due.Format(task.DateFormat)
if gotDate != expectedDate {
t.Errorf("expected due date %v, got %v", expectedDate, gotDate)
}
})
t.Run("clear due date", func(t *testing.T) {
baseWithDue := base.Clone()
baseWithDue.Due, _ = time.Parse(task.DateFormat, "2026-03-16")
action, err := ParseLaneAction("due=''")
if err != nil {
t.Fatalf("ParseLaneAction() error = %v", err)
}
result, err := ApplyLaneAction(baseWithDue, action, "")
if err != nil {
t.Fatalf("ApplyLaneAction() error = %v", err)
}
if !result.Due.IsZero() {
t.Errorf("expected zero due date, got %v", result.Due)
}
})
}
func TestParseLaneAction_EmptyString(t *testing.T) {
action, err := ParseLaneAction("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(action.Ops) != 0 {
t.Errorf("expected 0 ops for empty input, got %d", len(action.Ops))
}
}
func TestParseLaneAction_InvalidInteger(t *testing.T) {
_, err := ParseLaneAction("priority=abc")
if err == nil {
t.Fatal("expected error for non-integer priority")
}
if !strings.Contains(err.Error(), "invalid integer") {
t.Errorf("expected 'invalid integer' error, got: %v", err)
}
}
func TestApplyLaneAction_NilTask(t *testing.T) {
action := LaneAction{Ops: []LaneActionOp{{Field: ActionFieldStatus, Operator: ActionOperatorAssign, StrValue: "done"}}}
_, err := ApplyLaneAction(nil, action, "")
if err == nil {
t.Fatal("expected error for nil task")
}
if !strings.Contains(err.Error(), "task is nil") {
t.Errorf("expected 'task is nil' error, got: %v", err)
}
}
func TestApplyLaneAction_NoOps(t *testing.T) {
base := &task.Task{ID: "TASK-1", Title: "Task", Status: task.StatusBacklog}
result, err := ApplyLaneAction(base, LaneAction{}, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == base {
t.Error("expected clone, not original pointer")
}
if result.Title != "Task" {
t.Errorf("expected title 'Task', got %q", result.Title)
}
}
func TestApplyLaneAction_UnsupportedField(t *testing.T) {
base := &task.Task{ID: "TASK-1", Title: "Task", Status: task.StatusBacklog}
action := LaneAction{Ops: []LaneActionOp{{Field: "bogus", Operator: ActionOperatorAssign, StrValue: "x"}}}
_, err := ApplyLaneAction(base, action, "")
if err == nil {
t.Fatal("expected error for unsupported field")
}
if !strings.Contains(err.Error(), "unsupported action field") {
t.Errorf("expected 'unsupported action field' error, got: %v", err)
}
}

View file

@ -3,7 +3,7 @@ package plugin
import (
"github.com/gdamore/tcell/v2"
"github.com/boolean-maybe/tiki/plugin/filter"
"github.com/boolean-maybe/tiki/ruki"
)
// Plugin interface defines the common methods for all plugins
@ -64,7 +64,6 @@ func (p *BasePlugin) IsDefault() bool {
type TikiPlugin struct {
BasePlugin
Lanes []TikiLane // lane definitions for this plugin
Sort []SortRule // parsed sort rules (nil = default sort)
ViewMode string // default view mode: "compact" or "expanded" (empty = compact)
Actions []PluginAction // shortcut actions applied to the selected task
TaskID string // optional tiki associated with this plugin (code-only, not from workflow config)
@ -89,7 +88,7 @@ type PluginActionConfig struct {
type PluginAction struct {
Rune rune
Label string
Action LaneAction
Action *ruki.ValidatedStatement
}
// PluginLaneConfig represents a lane in YAML or config definitions.
@ -106,6 +105,6 @@ type TikiLane struct {
Name string
Columns int
Width int // lane width as a percentage (0 = equal share of remaining space)
Filter filter.FilterExpr
Action LaneAction
Filter *ruki.ValidatedStatement
Action *ruki.ValidatedStatement
}

View file

@ -1,27 +0,0 @@
package filter
import (
"fmt"
"regexp"
"strings"
"time"
"github.com/boolean-maybe/tiki/util/duration"
)
// durationPattern matches a number followed by a duration unit, with optional plural "s".
var durationPattern = regexp.MustCompile(`^(\d+)(` + duration.Pattern() + `)s?$`)
// IsDurationLiteral checks if a string is a valid duration literal.
func IsDurationLiteral(s string) bool {
return durationPattern.MatchString(strings.ToLower(s))
}
// ParseDuration parses a duration literal like "24hour" or "1week".
func ParseDuration(s string) (time.Duration, error) {
val, unit, err := duration.Parse(strings.ToLower(s))
if err != nil {
return 0, fmt.Errorf("invalid duration: %s", s)
}
return duration.ToDuration(val, unit)
}

View file

@ -1,206 +0,0 @@
package filter
import (
"fmt"
"strconv"
)
// parseComparison parses comparison expressions like: field op value
func (p *filterParser) parseComparison() (FilterExpr, error) {
// Parse left side (typically a field name or time expression)
leftValue, leftIsTimeExpr, err := p.parseValue()
if err != nil {
return nil, err
}
// Get operator
tok := p.current()
if tok.Type != TokenOperator {
return nil, fmt.Errorf("expected comparison operator, got %s", tok.Value)
}
op := tok.Value
p.advance()
// Parse right side
rightValue, rightIsTimeExpr, err := p.parseValue()
if err != nil {
return nil, err
}
// Build comparison expression
// If left side is a time expression like "NOW - CreatedAt", we need to handle it specially
if leftIsTimeExpr {
leftTimeExpr, _ := leftValue.(*TimeExpr)
// The comparison becomes: (NOW - CreatedAt) < 24hour
// which means: time.Since(CreatedAt) < 24hour
return &CompareExpr{
Field: "time_expr",
Op: op,
Value: &timeExprCompare{left: leftTimeExpr, right: rightValue},
}, nil
}
// Normal field comparison
fieldName, ok := leftValue.(string)
if !ok {
return nil, fmt.Errorf("expected field name on left side of comparison")
}
// If right side is a time expression, wrap it
if rightIsTimeExpr {
return &CompareExpr{
Field: fieldName,
Op: op,
Value: rightValue,
}, nil
}
return &CompareExpr{
Field: fieldName,
Op: op,
Value: rightValue,
}, nil
}
// parseValueGeneric parses a value with optional time expression support
// allowTimeExpr controls whether to parse time expressions like "NOW - 24hour"
// Returns the value and whether it's a time expression
func (p *filterParser) parseValueGeneric(allowTimeExpr bool) (interface{}, bool, error) {
tok := p.current()
switch tok.Type {
case TokenString:
p.advance()
return tok.Value, false, nil
case TokenNumber:
p.advance()
num, err := strconv.Atoi(tok.Value)
if err != nil {
return nil, false, fmt.Errorf("invalid number: %s", tok.Value)
}
return num, false, nil
case TokenDuration:
if !allowTimeExpr {
return nil, false, fmt.Errorf("duration not allowed in this context")
}
p.advance()
dur, err := ParseDuration(tok.Value)
if err != nil {
return nil, false, err
}
return &DurationValue{Duration: dur}, false, nil
case TokenIdent:
ident := tok.Value
p.advance()
// Check if this is a time expression (only if allowed)
if allowTimeExpr && isTimeField(ident) {
// Check if followed by + or -
if p.current().Type == TokenOperator {
opTok := p.current()
if opTok.Value == "+" || opTok.Value == "-" {
p.advance()
// Parse the operand (duration or another time field)
operand, _, err := p.parseTimeOperand()
if err != nil {
return nil, false, err
}
return &TimeExpr{Base: ident, Op: opTok.Value, Operand: operand}, true, nil
}
}
// Just a time field reference without arithmetic
return &TimeExpr{Base: ident}, true, nil
}
// Regular identifier (field name or special value like CURRENT_USER)
return ident, false, nil
default:
return nil, false, fmt.Errorf("unexpected token in value: %s", tok.Value)
}
}
// parseValue parses a value for comparisons (allows time expressions)
func (p *filterParser) parseValue() (interface{}, bool, error) {
return p.parseValueGeneric(true)
}
// parseTimeOperand parses the operand of a time expression (duration or field name)
func (p *filterParser) parseTimeOperand() (interface{}, bool, error) {
tok := p.current()
switch tok.Type {
case TokenDuration:
p.advance()
dur, err := ParseDuration(tok.Value)
if err != nil {
return nil, false, err
}
return dur, false, nil
case TokenIdent:
ident := tok.Value
p.advance()
// Time field names
if isTimeField(ident) {
return ident, true, nil
}
return nil, false, fmt.Errorf("expected duration or time field, got: %s", ident)
default:
return nil, false, fmt.Errorf("expected duration or time field, got: %s", tok.Value)
}
}
// parseInExpr parses: field IN [val1, val2, ...] or field NOT IN [...]
// This is called when we detect the IN pattern during primary expression parsing
func (p *filterParser) parseInExpr(fieldName string, isNotIn bool) (FilterExpr, error) {
// Expect opening bracket
if err := p.expect(TokenLBracket); err != nil {
return nil, fmt.Errorf("expected '[' after IN: %w", err)
}
// Parse list of values
var values []interface{}
// Handle empty list
if p.current().Type == TokenRBracket {
p.advance()
return &InExpr{Field: fieldName, Not: isNotIn, Values: values}, nil
}
for {
// Parse a value (string, number, or identifier like CURRENT_USER)
val, err := p.parseListValue()
if err != nil {
return nil, err
}
values = append(values, val)
// Check for comma (more values) or closing bracket (done)
tok := p.current()
if tok.Type == TokenRBracket {
p.advance()
break
}
if tok.Type == TokenComma {
p.advance()
continue
}
return nil, fmt.Errorf("expected ',' or ']' in list, got: %s", tok.Value)
}
return &InExpr{Field: fieldName, Not: isNotIn, Values: values}, nil
}
// parseListValue parses a single value in a list (string, number, or identifier)
// Does not allow durations or time expressions
func (p *filterParser) parseListValue() (interface{}, error) {
val, _, err := p.parseValueGeneric(false)
return val, err
}

View file

@ -1,481 +0,0 @@
package filter
import (
"strings"
"time"
"github.com/boolean-maybe/tiki/task"
"github.com/boolean-maybe/tiki/workflow"
)
// FilterExpr represents a filter expression that can be evaluated against a task
type FilterExpr interface {
Evaluate(task *task.Task, now time.Time, currentUser string) bool
}
// BinaryExpr represents AND, OR operations
type BinaryExpr struct {
Op string // "AND", "OR"
Left FilterExpr
Right FilterExpr
}
// Evaluate implements FilterExpr
func (b *BinaryExpr) Evaluate(task *task.Task, now time.Time, currentUser string) bool {
switch strings.ToUpper(b.Op) {
case "AND":
return b.Left.Evaluate(task, now, currentUser) && b.Right.Evaluate(task, now, currentUser)
case "OR":
return b.Left.Evaluate(task, now, currentUser) || b.Right.Evaluate(task, now, currentUser)
default:
return false
}
}
// UnaryExpr represents NOT operation
type UnaryExpr struct {
Op string // "NOT"
Expr FilterExpr
}
// Evaluate implements FilterExpr
func (u *UnaryExpr) Evaluate(task *task.Task, now time.Time, currentUser string) bool {
if strings.ToUpper(u.Op) == "NOT" {
return !u.Expr.Evaluate(task, now, currentUser)
}
return false
}
// CompareExpr represents comparisons like status = 'ready' or Priority < 3
type CompareExpr struct {
Field string // "status", "type", "assignee", "priority", "points", "createdat", "updatedat", "tags"
Op string // "=", "==", "!=", ">", "<", ">=", "<="
Value interface{} // string, int, or TimeExpr
}
// InExpr represents IN/NOT IN operations like: tags IN ['ui', 'charts', 'viz']
type InExpr struct {
Field string // "status", "type", "tags", etc.
Not bool // true for NOT IN, false for IN
Values []interface{} // List of values to check against (strings, ints, etc.)
}
// Evaluate implements FilterExpr for InExpr
func (i *InExpr) Evaluate(task *task.Task, now time.Time, currentUser string) bool {
// Handle CURRENT_USER and normalize status literals in the values list
isStatus := strings.ToLower(i.Field) == "status"
resolvedValues := make([]interface{}, len(i.Values))
for idx, val := range i.Values {
if strVal, ok := val.(string); ok && strings.ToUpper(strVal) == "CURRENT_USER" {
resolvedValues[idx] = currentUser
} else if isStatus {
if strVal, ok := val.(string); ok {
resolvedValues[idx] = string(workflow.NormalizeStatusKey(strVal))
} else {
resolvedValues[idx] = val
}
} else {
resolvedValues[idx] = val
}
}
// Special handling for array fields (tags, dependsOn)
fieldLower := strings.ToLower(i.Field)
if fieldLower == "tags" || fieldLower == "tag" || fieldLower == "dependson" {
var arrayField []string
switch fieldLower {
case "tags", "tag":
arrayField = task.Tags
case "dependson":
arrayField = task.DependsOn
}
result := evaluateTagsInComparison(arrayField, resolvedValues)
if i.Not {
return !result
}
return result
}
// For non-array fields, check if field value is in the list
fieldValue := getTaskAttribute(task, i.Field)
result := valueInList(fieldValue, resolvedValues)
if i.Not {
return !result
}
return result
}
// Evaluate implements FilterExpr for CompareExpr
func (c *CompareExpr) Evaluate(task *task.Task, now time.Time, currentUser string) bool {
// Handle time expression comparisons (e.g., NOW - CreatedAt < 24hour)
if c.Field == "time_expr" {
return c.evaluateTimeExpr(task, now)
}
fieldValue := getTaskAttribute(task, c.Field)
compareValue := c.Value
// Handle CURRENT_USER special value
if strVal, ok := compareValue.(string); ok && strings.ToUpper(strVal) == "CURRENT_USER" {
compareValue = currentUser
}
// normalize status literals so legacy forms (e.g. "in_progress") match camelCase keys
if strings.ToLower(c.Field) == "status" {
if strVal, ok := compareValue.(string); ok {
compareValue = string(workflow.NormalizeStatusKey(strVal))
}
}
// Handle TimeExpr (for NOW - CreatedAt type comparisons)
if timeExpr, ok := compareValue.(*TimeExpr); ok {
compareValue = timeExpr.Evaluate(task, now)
}
// Handle DurationValue
if dv, ok := compareValue.(*DurationValue); ok {
compareValue = dv.Duration
}
// Handle array fields specially - check if value is in the list
fieldLower := strings.ToLower(c.Field)
if fieldLower == "tags" || fieldLower == "tag" {
return evaluateTagComparison(task.Tags, c.Op, compareValue)
}
if fieldLower == "dependson" {
return evaluateTagComparison(task.DependsOn, c.Op, compareValue)
}
return compare(fieldValue, c.Op, compareValue)
}
// evaluateTimeExpr handles time expression comparisons like "NOW - CreatedAt < 24hour"
func (c *CompareExpr) evaluateTimeExpr(task *task.Task, now time.Time) bool {
tec, ok := c.Value.(*timeExprCompare)
if !ok {
return false
}
leftValue := tec.left.Evaluate(task, now)
rightValue := tec.right
// Handle DurationValue
if dv, ok := rightValue.(*DurationValue); ok {
rightValue = dv.Duration
}
return compare(leftValue, c.Op, rightValue)
}
// TimeExpr represents time arithmetic like NOW - 24hour or NOW - CreatedAt
type TimeExpr struct {
Base string // "NOW", "CreatedAt", "UpdatedAt", "Due"
Op string // "+", "-"
Operand interface{} // time.Duration or field name string
}
// Evaluate returns the computed time or duration value
func (t *TimeExpr) Evaluate(task *task.Task, now time.Time) interface{} {
var baseTime time.Time
switch strings.ToLower(t.Base) {
case "now":
baseTime = now
case "createdat":
baseTime = task.CreatedAt
case "updatedat":
baseTime = task.UpdatedAt
case "due":
if task.Due.IsZero() {
return nil
}
baseTime = task.Due
default:
return nil
}
if t.Op == "" {
return baseTime
}
// Handle duration operand
if dur, ok := t.Operand.(time.Duration); ok {
if t.Op == "-" {
return baseTime.Add(-dur)
}
return baseTime.Add(dur)
}
// Handle field name operand (e.g., NOW - CreatedAt returns duration)
if fieldName, ok := t.Operand.(string); ok {
var otherTime time.Time
switch strings.ToLower(fieldName) {
case "now":
otherTime = now
case "createdat":
otherTime = task.CreatedAt
case "updatedat":
otherTime = task.UpdatedAt
case "due":
if task.Due.IsZero() {
return nil
}
otherTime = task.Due
default:
return nil
}
if t.Op == "-" {
return baseTime.Sub(otherTime)
}
// Addition of times doesn't make sense, return nil
return nil
}
return baseTime
}
// DurationValue represents a parsed duration for comparison
type DurationValue struct {
Duration time.Duration
}
// timeExprCompare wraps a time expression comparison for evaluation
type timeExprCompare struct {
left *TimeExpr
right interface{}
}
// getTaskAttribute returns the value of a task field by name
func getTaskAttribute(task *task.Task, field string) interface{} {
switch strings.ToLower(field) {
case "status":
return string(task.Status)
case "type":
return string(task.Type)
case "assignee":
return task.Assignee
case "priority":
return task.Priority
case "points":
return task.Points
case "createdat":
return task.CreatedAt
case "updatedat":
return task.UpdatedAt
case "tags":
return task.Tags
case "dependson":
return task.DependsOn
case "due":
return task.Due
case "recurrence":
return string(task.Recurrence)
case "id":
return task.ID
case "title":
return task.Title
default:
return nil
}
}
// evaluateTagComparison checks if a tag matches the comparison
func evaluateTagComparison(tags []string, op string, value interface{}) bool {
strVal, ok := value.(string)
if !ok {
return false
}
// Check if any tag matches
found := false
for _, tag := range tags {
if strings.EqualFold(tag, strVal) {
found = true
break
}
}
switch op {
case "=", "==":
return found
case "!=":
return !found
default:
return false
}
}
// evaluateTagsInComparison checks if ANY task tag matches ANY value in the list
// Semantics: task.Tags ∩ values != ∅
func evaluateTagsInComparison(taskTags []string, values []interface{}) bool {
for _, taskTag := range taskTags {
for _, val := range values {
if strVal, ok := val.(string); ok {
if strings.EqualFold(taskTag, strVal) {
return true
}
}
}
}
return false
}
// valueInList checks if a single value exists in a list of values
func valueInList(fieldValue interface{}, values []interface{}) bool {
for _, val := range values {
// String comparison (case-insensitive)
if fvStr, ok := fieldValue.(string); ok {
if valStr, ok := val.(string); ok {
if strings.EqualFold(fvStr, valStr) {
return true
}
}
}
// Integer comparison
if fvInt, ok := fieldValue.(int); ok {
if valInt, ok := val.(int); ok {
if fvInt == valInt {
return true
}
}
}
// Direct equality for other types
if fieldValue == val {
return true
}
}
return false
}
// compare compares two values using the given operator
func compare(left interface{}, op string, right interface{}) bool {
// Normalize operator
if op == "==" {
op = "="
}
// String comparison
if leftStr, ok := left.(string); ok {
rightStr, ok := right.(string)
if !ok {
return false
}
return compareStrings(leftStr, op, rightStr)
}
// Integer comparison
if leftInt, ok := left.(int); ok {
rightInt, ok := right.(int)
if !ok {
return false
}
return compareInts(leftInt, op, rightInt)
}
// Time comparison
if leftTime, ok := left.(time.Time); ok {
if rightTime, ok := right.(time.Time); ok {
return compareTimes(leftTime, op, rightTime)
}
// Coerce string to date for comparison (e.g., due = '2026-03-16')
if rightStr, ok := right.(string); ok {
if parsed, ok := task.ParseDueDate(rightStr); ok {
return compareTimes(leftTime, op, parsed)
}
}
}
// Duration comparison
if leftDur, ok := left.(time.Duration); ok {
if rightDur, ok := right.(time.Duration); ok {
return compareDurations(leftDur, op, rightDur)
}
// Compare duration with DurationValue
if rightDurVal, ok := right.(*DurationValue); ok {
return compareDurations(leftDur, op, rightDurVal.Duration)
}
}
return false
}
func compareStrings(left, op, right string) bool {
// Case-insensitive comparison
left = strings.ToLower(left)
right = strings.ToLower(right)
switch op {
case "=":
return left == right
case "!=":
return left != right
case ">":
return left > right
case "<":
return left < right
case ">=":
return left >= right
case "<=":
return left <= right
default:
return false
}
}
func compareInts(left int, op string, right int) bool {
switch op {
case "=":
return left == right
case "!=":
return left != right
case ">":
return left > right
case "<":
return left < right
case ">=":
return left >= right
case "<=":
return left <= right
default:
return false
}
}
func compareTimes(left time.Time, op string, right time.Time) bool {
switch op {
case "=":
return left.Equal(right)
case "!=":
return !left.Equal(right)
case ">":
return left.After(right)
case "<":
return left.Before(right)
case ">=":
return left.After(right) || left.Equal(right)
case "<=":
return left.Before(right) || left.Equal(right)
default:
return false
}
}
func compareDurations(left time.Duration, op string, right time.Duration) bool {
switch op {
case "=":
return left == right
case "!=":
return left != right
case ">":
return left > right
case "<":
return left < right
case ">=":
return left >= right
case "<=":
return left <= right
default:
return false
}
}

View file

@ -1,491 +0,0 @@
package filter
import (
"testing"
"time"
"github.com/boolean-maybe/tiki/task"
)
// TestDoubleNegation tests that NOT NOT works correctly
func TestDoubleNegation(t *testing.T) {
tests := []struct {
name string
expr string
task *task.Task
expect bool
}{
{
name: "double negation - should match",
expr: "NOT NOT status = 'ready'",
task: &task.Task{Status: task.StatusReady},
expect: true, // NOT NOT true = NOT false = true
},
{
name: "double negation - should not match",
expr: "NOT NOT status = 'ready'",
task: &task.Task{Status: task.StatusDone},
expect: false, // NOT NOT false = NOT true = false
},
{
name: "double negation with parentheses",
expr: "NOT (NOT (status = 'ready'))",
task: &task.Task{Status: task.StatusReady},
expect: true,
},
{
name: "triple negation - odd number",
expr: "NOT NOT NOT status = 'done'",
task: &task.Task{Status: task.StatusReady},
expect: true, // NOT NOT NOT false = NOT NOT true = NOT false = true
},
{
name: "triple negation - cancels to NOT",
expr: "NOT NOT NOT status = 'done'",
task: &task.Task{Status: task.StatusDone},
expect: false, // NOT NOT NOT true = NOT NOT false = NOT true = false
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, err := ParseFilter(tt.expr)
if err != nil {
t.Fatalf("ParseFilter failed: %v", err)
}
result := filter.Evaluate(tt.task, time.Now(), "testuser")
if result != tt.expect {
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
}
})
}
}
// TestEmptyFilter tests handling of empty filter expressions
func TestEmptyFilter(t *testing.T) {
tests := []struct {
name string
expr string
expect bool // empty filter should match all tasks
}{
{
name: "empty string",
expr: "",
expect: true, // nil filter means no filtering
},
{
name: "whitespace only",
expr: " ",
expect: true, // trimmed to empty, should be nil filter
},
}
task := &task.Task{Status: task.StatusReady}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, err := ParseFilter(tt.expr)
if err != nil {
t.Fatalf("ParseFilter failed: %v", err)
}
// Empty filter returns nil
if filter == nil {
// Nil filter means match all - this is correct behavior
return
}
result := filter.Evaluate(task, time.Now(), "testuser")
if result != tt.expect {
t.Errorf("Expected %v, got %v for expression: %q", tt.expect, result, tt.expr)
}
})
}
}
// TestComplexNOTExpressions tests NOT with various complex expressions
func TestComplexNOTExpressions(t *testing.T) {
tests := []struct {
name string
expr string
task *task.Task
expect bool
}{
{
name: "NOT with AND - both conditions true",
expr: "NOT (status = 'ready' AND type = 'bug')",
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
expect: false, // NOT (true AND true) = NOT true = false
},
{
name: "NOT with AND - one condition false",
expr: "NOT (status = 'ready' AND type = 'bug')",
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
expect: true, // NOT (true AND false) = NOT false = true
},
{
name: "NOT with OR - both conditions true",
expr: "NOT (status = 'ready' OR type = 'bug')",
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
expect: false, // NOT (true OR true) = NOT true = false
},
{
name: "NOT with OR - one condition true",
expr: "NOT (status = 'ready' OR type = 'bug')",
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
expect: false, // NOT (true OR false) = NOT true = false
},
{
name: "NOT with OR - both conditions false",
expr: "NOT (status = 'ready' OR type = 'bug')",
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory},
expect: true, // NOT (false OR false) = NOT false = true
},
{
name: "NOT with complex mixed expression",
expr: "NOT (status = 'ready' AND type = 'bug' OR status = 'inProgress')",
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
expect: false, // NOT ((true AND true) OR false) = NOT (true OR false) = NOT true = false
},
{
name: "NOT with complex mixed expression - alternative match",
expr: "NOT (status = 'ready' AND type = 'bug' OR status = 'inProgress')",
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeStory},
expect: false, // NOT ((false AND false) OR true) = NOT (false OR true) = NOT true = false
},
{
name: "NOT with complex mixed expression - no match",
expr: "NOT (status = 'ready' AND type = 'bug' OR status = 'inProgress')",
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory},
expect: true, // NOT ((false AND false) OR false) = NOT false = true
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, err := ParseFilter(tt.expr)
if err != nil {
t.Fatalf("ParseFilter failed: %v", err)
}
result := filter.Evaluate(tt.task, time.Now(), "testuser")
if result != tt.expect {
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
}
})
}
}
// TestAllOperatorsCombined tests expressions using all available operators
func TestAllOperatorsCombined(t *testing.T) {
tests := []struct {
name string
expr string
task *task.Task
expect bool
}{
{
name: "all operators - all conditions match",
expr: "NOT status = 'done' AND (type IN ['bug', 'story'] OR priority > 3) AND tags NOT IN ['deprecated']",
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 5, Tags: []string{"active"}},
expect: true,
},
{
name: "all operators - NOT fails",
expr: "NOT status = 'done' AND (type IN ['bug', 'story'] OR priority > 3) AND tags NOT IN ['deprecated']",
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug, Priority: 5, Tags: []string{"active"}},
expect: false,
},
{
name: "all operators - IN fails",
expr: "NOT status = 'done' AND (type IN ['bug', 'story'] OR priority > 3) AND tags NOT IN ['deprecated']",
task: &task.Task{Status: task.StatusReady, Type: "epic", Priority: 2, Tags: []string{"active"}},
expect: false,
},
{
name: "all operators - NOT IN fails",
expr: "NOT status = 'done' AND (type IN ['bug', 'story'] OR priority > 3) AND tags NOT IN ['deprecated']",
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 5, Tags: []string{"deprecated"}},
expect: false,
},
{
name: "complex with comparisons and IN",
expr: "(priority >= 3 AND priority <= 5) OR (status IN ['ready', 'inProgress'] AND type = 'bug')",
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 2},
expect: true, // Second part matches
},
{
name: "complex with multiple NOT",
expr: "NOT status = 'done' AND NOT type = 'epic' AND NOT tags IN ['deprecated', 'archived']",
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Tags: []string{"active"}},
expect: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, err := ParseFilter(tt.expr)
if err != nil {
t.Fatalf("ParseFilter failed: %v", err)
}
result := filter.Evaluate(tt.task, time.Now(), "testuser")
if result != tt.expect {
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
}
})
}
}
// TestVeryLongExpressionChains tests that parser handles long chains correctly
func TestVeryLongExpressionChains(t *testing.T) {
tests := []struct {
name string
expr string
task *task.Task
expect bool
}{
{
name: "five AND chain",
expr: "status = 'ready' AND type = 'bug' AND priority > 2 AND priority < 6 AND points > 0",
task: &task.Task{
Status: task.StatusReady,
Type: task.TypeBug,
Priority: 4,
Points: 3,
},
expect: true,
},
{
name: "five OR chain - last matches",
expr: "status = 'done' OR status = 'cancelled' OR status = 'inProgress' OR status = 'review' OR status = 'ready'",
task: &task.Task{Status: task.StatusReady},
expect: true,
},
{
name: "alternating AND/OR chain",
expr: "status = 'ready' OR type = 'bug' AND priority > 3 OR points > 10 AND status = 'inProgress'",
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 2, Points: 5},
expect: true, // First OR condition matches
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, err := ParseFilter(tt.expr)
if err != nil {
t.Fatalf("ParseFilter failed: %v", err)
}
result := filter.Evaluate(tt.task, time.Now(), "testuser")
if result != tt.expect {
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
}
})
}
}
// TestComparisonOperators tests all comparison operators comprehensively
func TestComparisonOperators(t *testing.T) {
tests := []struct {
name string
expr string
task *task.Task
expect bool
}{
// Equality
{
name: "equality with =",
expr: "priority = 3",
task: &task.Task{Priority: 3},
expect: true,
},
{
name: "equality with ==",
expr: "priority == 3",
task: &task.Task{Priority: 3},
expect: true,
},
// Inequality
{
name: "inequality - not equal",
expr: "priority != 3",
task: &task.Task{Priority: 5},
expect: true,
},
{
name: "inequality - equal",
expr: "priority != 3",
task: &task.Task{Priority: 3},
expect: false,
},
// Greater than
{
name: "greater than - true",
expr: "priority > 3",
task: &task.Task{Priority: 5},
expect: true,
},
{
name: "greater than - false equal",
expr: "priority > 3",
task: &task.Task{Priority: 3},
expect: false,
},
{
name: "greater than - false less",
expr: "priority > 3",
task: &task.Task{Priority: 1},
expect: false,
},
// Less than
{
name: "less than - true",
expr: "priority < 3",
task: &task.Task{Priority: 1},
expect: true,
},
{
name: "less than - false equal",
expr: "priority < 3",
task: &task.Task{Priority: 3},
expect: false,
},
{
name: "less than - false greater",
expr: "priority < 3",
task: &task.Task{Priority: 5},
expect: false,
},
// Greater than or equal
{
name: "greater or equal - greater",
expr: "priority >= 3",
task: &task.Task{Priority: 5},
expect: true,
},
{
name: "greater or equal - equal",
expr: "priority >= 3",
task: &task.Task{Priority: 3},
expect: true,
},
{
name: "greater or equal - less",
expr: "priority >= 3",
task: &task.Task{Priority: 1},
expect: false,
},
// Less than or equal
{
name: "less or equal - less",
expr: "priority <= 3",
task: &task.Task{Priority: 1},
expect: true,
},
{
name: "less or equal - equal",
expr: "priority <= 3",
task: &task.Task{Priority: 3},
expect: true,
},
{
name: "less or equal - greater",
expr: "priority <= 3",
task: &task.Task{Priority: 5},
expect: false,
},
// String comparisons (lexicographic)
{
name: "string greater than",
expr: "status > 'inProgress'",
task: &task.Task{Status: task.StatusReady},
expect: true, // "ready" > "inProgress" lexicographically
},
{
name: "string less than",
expr: "status < 'ready'",
task: &task.Task{Status: task.StatusDone},
expect: true, // "done" < "ready" lexicographically
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, err := ParseFilter(tt.expr)
if err != nil {
t.Fatalf("ParseFilter failed: %v", err)
}
result := filter.Evaluate(tt.task, time.Now(), "testuser")
if result != tt.expect {
t.Errorf("Expected %v, got %v for expression: %s (priority=%d, status=%s)",
tt.expect, result, tt.expr, tt.task.Priority, tt.task.Status)
}
})
}
}
// TestRealWorldScenarios tests realistic filter expressions users might write
func TestRealWorldScenarios(t *testing.T) {
now := time.Now()
tests := []struct {
name string
expr string
task *task.Task
expect bool
}{
{
name: "recent high-priority bugs",
expr: "type = 'bug' AND priority >= 4 AND NOW - CreatedAt < 7days",
task: &task.Task{Type: task.TypeBug, Priority: 5, CreatedAt: now.Add(-3 * 24 * time.Hour)},
expect: true,
},
{
name: "stale tasks needing attention",
expr: "status IN ['ready', 'inProgress', 'inProgress'] AND NOW - UpdatedAt > 14days",
task: &task.Task{Status: task.StatusReady, UpdatedAt: now.Add(-20 * 24 * time.Hour)},
expect: true,
},
{
name: "UI/UX work in progress",
expr: "tags IN ['ui', 'ux', 'design'] AND status IN ['ready', 'inProgress'] AND type != 'epic'",
task: &task.Task{Tags: []string{"ui", "frontend"}, Status: task.StatusInProgress, Type: task.TypeStory},
expect: true,
},
{
name: "ready for release",
expr: "status = 'done' AND type IN ['story', 'bug'] AND tags NOT IN ['not-deployable', 'experimental']",
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory, Tags: []string{"ready"}},
expect: true,
},
{
name: "blocked high-value items",
expr: "status = 'inProgress' AND (priority >= 4 OR points >= 8)",
task: &task.Task{Status: task.StatusInProgress, Priority: 2, Points: 10},
expect: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, err := ParseFilter(tt.expr)
if err != nil {
t.Fatalf("ParseFilter failed: %v", err)
}
result := filter.Evaluate(tt.task, now, "testuser")
if result != tt.expect {
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
}
})
}
}

View file

@ -1,42 +0,0 @@
package filter
import (
"fmt"
"strings"
)
// ParseFilter parses a filter expression string into an AST.
// This is the main public entry point for filter parsing.
//
// Example expressions:
// - status = 'done'
// - type = 'bug' AND priority > 2
// - status IN ['ready', 'in_progress']
// - NOW - CreatedAt < 24hour
// - (status = 'ready' OR status = 'in_progress') AND priority >= 3
//
// Returns nil expression for empty string (no filtering).
func ParseFilter(expr string) (FilterExpr, error) {
expr = strings.TrimSpace(expr)
if expr == "" {
return nil, nil // empty filter = no filtering (all tasks pass)
}
tokens, err := Tokenize(expr)
if err != nil {
return nil, err
}
parser := newFilterParser(tokens)
result, err := parser.parseExpr()
if err != nil {
return nil, err
}
// Ensure we consumed all tokens
if parser.pos < len(parser.tokens) && parser.tokens[parser.pos].Type != TokenEOF {
return nil, fmt.Errorf("unexpected token at position %d: %s", parser.pos, parser.tokens[parser.pos].Value)
}
return result, nil
}

View file

@ -1,351 +0,0 @@
package filter
import (
"testing"
"time"
"github.com/boolean-maybe/tiki/task"
)
func TestParseFilterWithIn(t *testing.T) {
tests := []struct {
name string
expr string
task *task.Task
expect bool
}{
{
name: "tags IN with match",
expr: "tags IN ['ui', 'charts']",
task: &task.Task{Tags: []string{"ui", "backend"}},
expect: true,
},
{
name: "tags IN with no match",
expr: "tags IN ['frontend', 'api']",
task: &task.Task{Tags: []string{"ui", "backend"}},
expect: false,
},
{
name: "tags IN with single value match",
expr: "tags IN ['ui']",
task: &task.Task{Tags: []string{"ui", "backend"}},
expect: true,
},
{
name: "tags IN empty list",
expr: "tags IN []",
task: &task.Task{Tags: []string{"ui"}},
expect: false,
},
{
name: "status IN with match",
expr: "status IN ['ready', 'inProgress']",
task: &task.Task{Status: task.StatusReady},
expect: true,
},
{
name: "status IN with no match",
expr: "status IN ['done', 'cancelled']",
task: &task.Task{Status: task.StatusReady},
expect: false,
},
{
name: "status NOT IN with match",
expr: "status NOT IN ['done', 'cancelled']",
task: &task.Task{Status: task.StatusReady},
expect: true,
},
{
name: "status NOT IN with no match",
expr: "status NOT IN ['ready', 'inProgress']",
task: &task.Task{Status: task.StatusReady},
expect: false,
},
{
name: "type IN with match",
expr: "type IN ['story', 'bug']",
task: &task.Task{Type: task.TypeStory},
expect: true,
},
{
name: "priority IN with integers",
expr: "priority IN [1, 2, 3]",
task: &task.Task{Priority: 2},
expect: true,
},
{
name: "priority IN with no match",
expr: "priority IN [1, 3, 5]",
task: &task.Task{Priority: 2},
expect: false,
},
{
name: "combined with AND",
expr: "tags IN ['ui', 'charts'] AND status = 'ready'",
task: &task.Task{Tags: []string{"ui"}, Status: task.StatusReady},
expect: true,
},
{
name: "combined with AND, no match",
expr: "tags IN ['ui', 'charts'] AND status = 'done'",
task: &task.Task{Tags: []string{"ui"}, Status: task.StatusReady},
expect: false,
},
{
name: "combined with OR",
expr: "tags IN ['ui'] OR tags IN ['backend']",
task: &task.Task{Tags: []string{"backend"}},
expect: true,
},
{
name: "case insensitive tags",
expr: "tags IN ['UI', 'Charts']",
task: &task.Task{Tags: []string{"ui", "charts"}},
expect: true,
},
{
name: "case insensitive status",
expr: "status IN ['READY', 'IN_PROGRESS']",
task: &task.Task{Status: task.StatusReady},
expect: true,
},
{
name: "snake_case filter matches camelCase task status",
expr: "status = 'in_progress'",
task: &task.Task{Status: task.StatusInProgress},
expect: true,
},
{
name: "snake_case IN filter matches camelCase task status",
expr: "status IN ['in_progress', 'review']",
task: &task.Task{Status: task.StatusInProgress},
expect: true,
},
{
name: "NOT with IN expression",
expr: "NOT (tags IN ['deprecated', 'archived'])",
task: &task.Task{Tags: []string{"ui", "active"}},
expect: true,
},
{
name: "complex expression",
expr: "(tags IN ['ui', 'frontend'] OR type = 'bug') AND status NOT IN ['done']",
task: &task.Task{Tags: []string{"ui"}, Type: task.TypeStory, Status: task.StatusReady},
expect: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, err := ParseFilter(tt.expr)
if err != nil {
t.Fatalf("ParseFilter failed: %v", err)
}
result := filter.Evaluate(tt.task, time.Now(), "testuser")
if result != tt.expect {
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
}
})
}
}
func TestTokenizeWithIn(t *testing.T) {
tests := []struct {
name string
input string
expected []TokenType
}{
{
name: "simple IN expression",
input: "tags IN ['ui']",
expected: []TokenType{TokenIdent, TokenIn, TokenLBracket,
TokenString, TokenRBracket, TokenEOF},
},
{
name: "NOT IN expression",
input: "status NOT IN ['done']",
expected: []TokenType{TokenIdent, TokenNotIn, TokenLBracket,
TokenString, TokenRBracket, TokenEOF},
},
{
name: "multiple values with commas",
input: "type IN ['bug', 'story', 'epic']",
expected: []TokenType{TokenIdent, TokenIn, TokenLBracket,
TokenString, TokenComma, TokenString,
TokenComma, TokenString, TokenRBracket, TokenEOF},
},
{
name: "IN with numbers",
input: "priority IN [1, 2, 3]",
expected: []TokenType{TokenIdent, TokenIn, TokenLBracket,
TokenNumber, TokenComma, TokenNumber,
TokenComma, TokenNumber, TokenRBracket, TokenEOF},
},
{
name: "empty list",
input: "tags IN []",
expected: []TokenType{TokenIdent, TokenIn, TokenLBracket, TokenRBracket, TokenEOF},
},
{
name: "IN with AND",
input: "tags IN ['ui'] AND status = 'ready'",
expected: []TokenType{TokenIdent, TokenIn, TokenLBracket, TokenString, TokenRBracket,
TokenAnd, TokenIdent, TokenOperator, TokenString, TokenEOF},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tokens, err := Tokenize(tt.input)
if err != nil {
t.Fatalf("tokenize failed: %v", err)
}
if len(tokens) != len(tt.expected) {
t.Fatalf("Expected %d tokens, got %d", len(tt.expected), len(tokens))
}
for i, tok := range tokens {
if tok.Type != tt.expected[i] {
t.Errorf("Token %d: expected type %d, got %d (value: %s)",
i, tt.expected[i], tok.Type, tok.Value)
}
}
})
}
}
func TestParseFilterErrors(t *testing.T) {
tests := []struct {
name string
expr string
errMsg string
}{
{
name: "missing opening bracket",
expr: "tags IN 'ui', 'charts']",
errMsg: "expected '['",
},
{
name: "missing closing bracket",
expr: "tags IN ['ui', 'charts'",
errMsg: "expected ','",
},
{
name: "missing comma",
expr: "tags IN ['ui' 'charts']",
errMsg: "expected ','",
},
{
name: "invalid value in list",
expr: "tags IN ['ui', =]",
errMsg: "unexpected token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseFilter(tt.expr)
if err == nil {
t.Fatal("Expected error, got nil")
}
// Could check error message contains expected substring if needed
})
}
}
func TestInExprWithCurrentUser(t *testing.T) {
tests := []struct {
name string
expr string
task *task.Task
currentUser string
expect bool
}{
{
name: "assignee IN with CURRENT_USER match",
expr: "assignee IN ['alice', CURRENT_USER, 'bob']",
task: &task.Task{Assignee: "testuser"},
currentUser: "testuser",
expect: true,
},
{
name: "assignee IN with CURRENT_USER no match",
expr: "assignee IN ['alice', CURRENT_USER, 'bob']",
task: &task.Task{Assignee: "charlie"},
currentUser: "testuser",
expect: false,
},
{
name: "assignee IN with only CURRENT_USER",
expr: "assignee IN [CURRENT_USER]",
task: &task.Task{Assignee: "testuser"},
currentUser: "testuser",
expect: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, err := ParseFilter(tt.expr)
if err != nil {
t.Fatalf("ParseFilter failed: %v", err)
}
result := filter.Evaluate(tt.task, time.Now(), tt.currentUser)
if result != tt.expect {
t.Errorf("Expected %v, got %v", tt.expect, result)
}
})
}
}
func TestInExprBackwardCompatibility(t *testing.T) {
tests := []struct {
name string
expr string
task *task.Task
expect bool
}{
{
name: "old style tag comparison",
expr: "tag = 'ui'",
task: &task.Task{Tags: []string{"ui", "frontend"}},
expect: true,
},
{
name: "old style OR chain",
expr: "(tag = 'ui' OR tag = 'charts' OR tag = 'viz')",
task: &task.Task{Tags: []string{"charts"}},
expect: true,
},
{
name: "status comparison",
expr: "status = 'ready'",
task: &task.Task{Status: task.StatusReady},
expect: true,
},
{
name: "complex old style expression",
expr: "status = 'ready' AND (tag = 'ui' OR tag = 'backend')",
task: &task.Task{Status: task.StatusReady, Tags: []string{"ui"}},
expect: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, err := ParseFilter(tt.expr)
if err != nil {
t.Fatalf("ParseFilter failed: %v", err)
}
result := filter.Evaluate(tt.task, time.Now(), "testuser")
if result != tt.expect {
t.Errorf("Expected %v, got %v", tt.expect, result)
}
})
}
}

View file

@ -1,375 +0,0 @@
package filter
import (
"testing"
"time"
"github.com/boolean-maybe/tiki/task"
)
// TestOperatorPrecedence tests that operators follow correct precedence: NOT > AND > OR
func TestOperatorPrecedence(t *testing.T) {
tests := []struct {
name string
expr string
task *task.Task
expect bool
}{
// NOT has highest precedence
{
name: "NOT before AND",
expr: "NOT status = 'done' AND type = 'bug'",
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
expect: true, // (NOT (status = 'done')) AND (type = 'bug') = true AND true = true
},
{
name: "NOT before AND - false case",
expr: "NOT status = 'done' AND type = 'bug'",
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
expect: false, // (NOT (status = 'done')) AND (type = 'bug') = true AND false = false
},
{
name: "NOT before AND - with done status",
expr: "NOT status = 'done' AND type = 'bug'",
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug},
expect: false, // (NOT (status = 'done')) AND (type = 'bug') = false AND true = false
},
// AND before OR - left side
{
name: "AND before OR - left match",
expr: "status = 'ready' OR status = 'inProgress' AND type = 'bug'",
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
expect: true, // status = 'ready' OR (status = 'inProgress' AND type = 'bug') = true OR false = true
},
{
name: "AND before OR - right match",
expr: "status = 'ready' OR status = 'inProgress' AND type = 'bug'",
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug},
expect: true, // status = 'ready' OR (status = 'inProgress' AND type = 'bug') = false OR true = true
},
{
name: "AND before OR - no match",
expr: "status = 'ready' OR status = 'inProgress' AND type = 'bug'",
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeStory},
expect: false, // status = 'ready' OR (status = 'inProgress' AND type = 'bug') = false OR false = false
},
// AND before OR - right side
{
name: "AND before OR - right side left match",
expr: "status = 'inProgress' AND type = 'bug' OR status = 'ready'",
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
expect: true, // (status = 'inProgress' AND type = 'bug') OR status = 'ready' = false OR true = true
},
{
name: "AND before OR - right side right match",
expr: "status = 'inProgress' AND type = 'bug' OR status = 'ready'",
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug},
expect: true, // (status = 'inProgress' AND type = 'bug') OR status = 'ready' = true OR false = true
},
// Parentheses override precedence
{
name: "parentheses override AND/OR precedence - no match",
expr: "(status = 'ready' OR status = 'inProgress') AND type = 'bug'",
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
expect: false, // (status = 'ready' OR status = 'inProgress') AND type = 'bug' = true AND false = false
},
{
name: "parentheses override AND/OR precedence - match",
expr: "(status = 'ready' OR status = 'inProgress') AND type = 'bug'",
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug},
expect: true, // (status = 'ready' OR status = 'inProgress') AND type = 'bug' = true AND true = true
},
// NOT with OR
{
name: "NOT before OR",
expr: "NOT status = 'done' OR type = 'bug'",
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory},
expect: false, // (NOT (status = 'done')) OR (type = 'bug') = false OR false = false
},
{
name: "NOT before OR - match on NOT",
expr: "NOT status = 'done' OR type = 'bug'",
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
expect: true, // (NOT (status = 'done')) OR (type = 'bug') = true OR false = true
},
{
name: "NOT before OR - match on type",
expr: "NOT status = 'done' OR type = 'bug'",
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug},
expect: true, // (NOT (status = 'done')) OR (type = 'bug') = false OR true = true
},
// Complex precedence: NOT > AND > OR
{
name: "NOT > AND > OR - all operators",
expr: "NOT status = 'done' AND type = 'bug' OR priority > 3",
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 5},
expect: true, // ((NOT (status = 'done')) AND type = 'bug') OR priority > 3 = true OR true = true
},
{
name: "NOT > AND > OR - match on OR only",
expr: "NOT status = 'done' AND type = 'bug' OR priority > 3",
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 5},
expect: true, // ((NOT (status = 'done')) AND type = 'bug') OR priority > 3 = false OR true = true
},
{
name: "NOT > AND > OR - match on AND only",
expr: "NOT status = 'done' AND type = 'bug' OR priority > 3",
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 2},
expect: true, // ((NOT (status = 'done')) AND type = 'bug') OR priority > 3 = true OR false = true
},
{
name: "NOT > AND > OR - no match",
expr: "NOT status = 'done' AND type = 'bug' OR priority > 3",
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory, Priority: 1},
expect: false, // ((NOT (status = 'done')) AND type = 'bug') OR priority > 3 = false OR false = false
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, err := ParseFilter(tt.expr)
if err != nil {
t.Fatalf("ParseFilter failed: %v", err)
}
result := filter.Evaluate(tt.task, time.Now(), "testuser")
if result != tt.expect {
t.Errorf("Expected %v, got %v for expression: %s\nTask: status=%s, type=%s, priority=%d",
tt.expect, result, tt.expr, tt.task.Status, tt.task.Type, tt.task.Priority)
}
})
}
}
// TestNestedParentheses tests deeply nested parentheses expressions
func TestNestedParentheses(t *testing.T) {
tests := []struct {
name string
expr string
task *task.Task
expect bool
}{
// Double nesting
{
name: "double nested parentheses - match",
expr: "((status = 'ready' OR status = 'inProgress') AND type = 'bug')",
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
expect: true,
},
{
name: "double nested parentheses - no match on type",
expr: "((status = 'ready' OR status = 'inProgress') AND type = 'bug')",
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
expect: false,
},
{
name: "double nested parentheses - no match on status",
expr: "((status = 'ready' OR status = 'inProgress') AND type = 'bug')",
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug},
expect: false,
},
// Triple nesting with NOT
{
name: "triple nested with NOT - match",
expr: "NOT (((status = 'done' OR status = 'cancelled') AND priority < 3))",
task: &task.Task{Status: task.StatusReady, Priority: 2},
expect: true, // NOT ((false OR false) AND true) = NOT (false AND true) = NOT false = true
},
{
name: "triple nested with NOT - no match",
expr: "NOT (((status = 'done' OR status = 'cancelled') AND priority < 3))",
task: &task.Task{Status: task.StatusDone, Priority: 2},
expect: false, // NOT ((true OR false) AND true) = NOT (true AND true) = NOT true = false
},
{
name: "triple nested with NOT - no match on priority",
expr: "NOT (((status = 'done' OR status = 'cancelled') AND priority < 3))",
task: &task.Task{Status: task.StatusDone, Priority: 5},
expect: true, // NOT ((true OR false) AND false) = NOT (true AND false) = NOT false = true
},
// Mixed nesting depth
{
name: "mixed nesting depth - OR at end",
expr: "(status = 'ready' AND (type = 'bug' OR type = 'story')) OR status = 'inProgress'",
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug},
expect: true, // (false AND (true OR false)) OR true = false OR true = true
},
{
name: "mixed nesting depth - match on nested OR",
expr: "(status = 'ready' AND (type = 'bug' OR type = 'story')) OR status = 'inProgress'",
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
expect: true, // (true AND (true OR false)) OR false = true OR false = true
},
{
name: "mixed nesting depth - match on final OR",
expr: "(status = 'ready' AND (type = 'bug' OR type = 'story')) OR status = 'inProgress'",
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug},
expect: true, // (false AND (true OR false)) OR true = false OR true = true
},
// Complex nested with multiple operations
{
name: "complex nested - all conditions",
expr: "((status = 'ready' OR status = 'inProgress') AND (type = 'bug' OR priority > 3))",
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 5},
expect: true, // (true OR false) AND (false OR true) = true AND true = true
},
{
name: "complex nested - left fails",
expr: "((status = 'ready' OR status = 'inProgress') AND (type = 'bug' OR priority > 3))",
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory, Priority: 5},
expect: false, // (false OR false) AND (false OR true) = false AND true = false
},
{
name: "complex nested - right fails",
expr: "((status = 'ready' OR status = 'inProgress') AND (type = 'bug' OR priority > 3))",
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 2},
expect: false, // (true OR false) AND (false OR false) = true AND false = false
},
// Nested with NOT at different levels
{
name: "NOT outside nested expression",
expr: "NOT ((status = 'done' AND type = 'bug') OR priority < 2)",
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug, Priority: 3},
expect: false, // NOT ((true AND true) OR false) = NOT (true OR false) = NOT true = false
},
{
name: "NOT inside nested expression",
expr: "(NOT status = 'done' AND type = 'bug') OR priority > 5",
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 3},
expect: true, // ((NOT false) AND true) OR false = (true AND true) OR false = true
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, err := ParseFilter(tt.expr)
if err != nil {
t.Fatalf("ParseFilter failed: %v", err)
}
result := filter.Evaluate(tt.task, time.Now(), "testuser")
if result != tt.expect {
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
}
})
}
}
// TestComplexBooleanChains tests multiple operators chained together
func TestComplexBooleanChains(t *testing.T) {
tests := []struct {
name string
expr string
task *task.Task
expect bool
}{
// Triple AND chain
{
name: "triple AND chain - all match",
expr: "status = 'ready' AND type = 'bug' AND priority > 3",
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 5},
expect: true,
},
{
name: "triple AND chain - first fails",
expr: "status = 'ready' AND type = 'bug' AND priority > 3",
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug, Priority: 5},
expect: false,
},
{
name: "triple AND chain - middle fails",
expr: "status = 'ready' AND type = 'bug' AND priority > 3",
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 5},
expect: false,
},
{
name: "triple AND chain - last fails",
expr: "status = 'ready' AND type = 'bug' AND priority > 3",
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 2},
expect: false,
},
// Triple OR chain
{
name: "triple OR chain - first matches",
expr: "status = 'ready' OR status = 'inProgress' OR status = 'inProgress'",
task: &task.Task{Status: task.StatusReady},
expect: true,
},
{
name: "triple OR chain - middle matches",
expr: "status = 'ready' OR status = 'inProgress' OR status = 'inProgress'",
task: &task.Task{Status: task.StatusInProgress},
expect: true,
},
{
name: "triple OR chain - last matches",
expr: "status = 'ready' OR status = 'inProgress' OR status = 'inProgress'",
task: &task.Task{Status: task.StatusInProgress},
expect: true,
},
{
name: "triple OR chain - none match",
expr: "status = 'ready' OR status = 'inProgress' OR status = 'inProgress'",
task: &task.Task{Status: task.StatusDone},
expect: false,
},
// Mixed chain without parentheses - tests precedence
{
name: "mixed chain A OR B AND C - match on A",
expr: "status = 'ready' OR type = 'bug' AND priority > 3",
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 2},
expect: true, // status = 'ready' OR (type = 'bug' AND priority > 3) = true OR false = true
},
{
name: "mixed chain A OR B AND C - match on B AND C",
expr: "status = 'ready' OR type = 'bug' AND priority > 3",
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug, Priority: 5},
expect: true, // status = 'ready' OR (type = 'bug' AND priority > 3) = false OR true = true
},
{
name: "mixed chain A OR B AND C - no match",
expr: "status = 'ready' OR type = 'bug' AND priority > 3",
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeStory, Priority: 2},
expect: false, // status = 'ready' OR (type = 'bug' AND priority > 3) = false OR false = false
},
// Longer chains
{
name: "four operator chain - AND heavy",
expr: "status = 'ready' AND type = 'bug' AND priority > 3 AND points < 10",
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 5, Points: 8},
expect: true,
},
{
name: "four operator chain - mixed AND/OR",
expr: "status = 'ready' AND type = 'bug' OR status = 'inProgress' AND priority > 3",
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeStory, Priority: 5},
expect: true, // (status = 'ready' AND type = 'bug') OR (status = 'inProgress' AND priority > 3) = false OR true = true
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, err := ParseFilter(tt.expr)
if err != nil {
t.Fatalf("ParseFilter failed: %v", err)
}
result := filter.Evaluate(tt.task, time.Now(), "testuser")
if result != tt.expect {
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
}
})
}
}

View file

@ -1,414 +0,0 @@
package filter
import (
"testing"
"time"
"github.com/boolean-maybe/tiki/task"
)
// TestTimeExpressions tests time-based filter expressions like "NOW - CreatedAt < 24hour"
func TestTimeExpressions(t *testing.T) {
now := time.Now()
tests := []struct {
name string
expr string
task *task.Task
expect bool
}{
// NOW - UpdatedAt comparisons
{
name: "recent task - under 2 hours",
expr: "NOW - UpdatedAt < 2hours",
task: &task.Task{UpdatedAt: now.Add(-1 * time.Hour)},
expect: true, // Updated 1 hour ago, less than 2 hours
},
{
name: "old task - over 2 hours",
expr: "NOW - UpdatedAt < 2hours",
task: &task.Task{UpdatedAt: now.Add(-3 * time.Hour)},
expect: false, // Updated 3 hours ago, more than 2 hours
},
{
name: "exact boundary - 2 hours",
expr: "NOW - UpdatedAt < 2hours",
task: &task.Task{UpdatedAt: now.Add(-2 * time.Hour)},
expect: false, // Updated exactly 2 hours ago, not less than 2 hours
},
// Greater than comparisons
{
name: "old task - over 1 week",
expr: "NOW - UpdatedAt > 1week",
task: &task.Task{UpdatedAt: now.Add(-10 * 24 * time.Hour)},
expect: true, // Updated 10 days ago, more than 1 week
},
{
name: "recent task - under 1 week",
expr: "NOW - UpdatedAt > 1week",
task: &task.Task{UpdatedAt: now.Add(-3 * 24 * time.Hour)},
expect: false, // Updated 3 days ago, less than 1 week
},
// NOW - CreatedAt comparisons
{
name: "recently created - under 1 month",
expr: "NOW - CreatedAt < 1month",
task: &task.Task{CreatedAt: now.Add(-15 * 24 * time.Hour)},
expect: true, // Created 15 days ago, less than 30 days (1 month)
},
{
name: "old creation - over 1 month",
expr: "NOW - CreatedAt < 1month",
task: &task.Task{CreatedAt: now.Add(-40 * 24 * time.Hour)},
expect: false, // Created 40 days ago, more than 30 days
},
// Less than or equal comparisons
{
name: "task age <= 24 hours - exact match",
expr: "NOW - UpdatedAt <= 24hours",
task: &task.Task{UpdatedAt: now.Add(-24 * time.Hour)},
expect: true, // Updated exactly 24 hours ago
},
{
name: "task age <= 24 hours - under",
expr: "NOW - UpdatedAt <= 24hours",
task: &task.Task{UpdatedAt: now.Add(-12 * time.Hour)},
expect: true, // Updated 12 hours ago
},
{
name: "task age <= 24 hours - over",
expr: "NOW - UpdatedAt <= 24hours",
task: &task.Task{UpdatedAt: now.Add(-30 * time.Hour)},
expect: false, // Updated 30 hours ago
},
// Greater than or equal comparisons
{
name: "task age >= 1 day - exact match",
expr: "NOW - CreatedAt >= 1day",
task: &task.Task{CreatedAt: now.Add(-24 * time.Hour)},
expect: true, // Created exactly 1 day ago
},
{
name: "task age >= 1 day - over",
expr: "NOW - CreatedAt >= 1day",
task: &task.Task{CreatedAt: now.Add(-48 * time.Hour)},
expect: true, // Created 2 days ago
},
{
name: "task age >= 1 day - under",
expr: "NOW - CreatedAt >= 1day",
task: &task.Task{CreatedAt: now.Add(-12 * time.Hour)},
expect: false, // Created 12 hours ago
},
// Different duration units
{
name: "minutes - under threshold",
expr: "NOW - UpdatedAt < 60min",
task: &task.Task{UpdatedAt: now.Add(-30 * time.Minute)},
expect: true, // Updated 30 minutes ago
},
{
name: "days - over threshold",
expr: "NOW - UpdatedAt > 7days",
task: &task.Task{UpdatedAt: now.Add(-10 * 24 * time.Hour)},
expect: true, // Updated 10 days ago
},
{
name: "weeks - under threshold",
expr: "NOW - CreatedAt < 2weeks",
task: &task.Task{CreatedAt: now.Add(-10 * 24 * time.Hour)},
expect: true, // Created 10 days ago, less than 14 days
},
// Combined with other conditions
{
name: "time condition AND status",
expr: "NOW - UpdatedAt < 24hours AND status = 'ready'",
task: &task.Task{UpdatedAt: now.Add(-12 * time.Hour), Status: task.StatusReady},
expect: true,
},
{
name: "time condition AND status - status mismatch",
expr: "NOW - UpdatedAt < 24hours AND status = 'ready'",
task: &task.Task{UpdatedAt: now.Add(-12 * time.Hour), Status: task.StatusDone},
expect: false,
},
{
name: "time condition AND status - time mismatch",
expr: "NOW - UpdatedAt < 24hours AND status = 'ready'",
task: &task.Task{UpdatedAt: now.Add(-48 * time.Hour), Status: task.StatusReady},
expect: false,
},
// Time condition OR other conditions
{
name: "time condition OR type - time matches",
expr: "NOW - UpdatedAt < 1hour OR type = 'bug'",
task: &task.Task{UpdatedAt: now.Add(-30 * time.Minute), Type: task.TypeStory},
expect: true,
},
{
name: "time condition OR type - type matches",
expr: "NOW - UpdatedAt < 1hour OR type = 'bug'",
task: &task.Task{UpdatedAt: now.Add(-5 * time.Hour), Type: task.TypeBug},
expect: true,
},
{
name: "time condition OR type - neither matches",
expr: "NOW - UpdatedAt < 1hour OR type = 'bug'",
task: &task.Task{UpdatedAt: now.Add(-5 * time.Hour), Type: task.TypeStory},
expect: false,
},
// NOT with time conditions
{
name: "NOT time condition - should match",
expr: "NOT (NOW - UpdatedAt < 24hours)",
task: &task.Task{UpdatedAt: now.Add(-48 * time.Hour)},
expect: true, // Updated 48 hours ago, NOT less than 24 hours
},
{
name: "NOT time condition - should not match",
expr: "NOT (NOW - UpdatedAt < 24hours)",
task: &task.Task{UpdatedAt: now.Add(-12 * time.Hour)},
expect: false, // Updated 12 hours ago, NOT (less than 24 hours) = false
},
// Equality (rarely useful but should work)
{
name: "time equality - not equal",
expr: "NOW - UpdatedAt = 24hours",
task: &task.Task{UpdatedAt: now.Add(-25 * time.Hour)},
expect: false, // Small timing differences make exact equality unlikely
},
// Inequality
{
name: "time inequality - not equal",
expr: "NOW - UpdatedAt != 0min",
task: &task.Task{UpdatedAt: now.Add(-5 * time.Minute)},
expect: true, // Updated 5 minutes ago, not equal to 0
},
// Edge case: very recent update (near zero duration)
{
name: "very recent update",
expr: "NOW - UpdatedAt < 1min",
task: &task.Task{UpdatedAt: now.Add(-5 * time.Second)},
expect: true, // Updated 5 seconds ago
},
// seconds unit
{
name: "seconds - under threshold",
expr: "NOW - UpdatedAt < 30secs",
task: &task.Task{UpdatedAt: now.Add(-10 * time.Second)},
expect: true, // Updated 10 seconds ago
},
{
name: "seconds - over threshold",
expr: "NOW - UpdatedAt < 10sec",
task: &task.Task{UpdatedAt: now.Add(-30 * time.Second)},
expect: false, // Updated 30 seconds ago
},
// years unit
{
name: "years - under threshold",
expr: "NOW - CreatedAt < 1year",
task: &task.Task{CreatedAt: now.Add(-200 * 24 * time.Hour)},
expect: true, // Created 200 days ago, less than 365 days
},
{
name: "years - over threshold",
expr: "NOW - CreatedAt > 1year",
task: &task.Task{CreatedAt: now.Add(-400 * 24 * time.Hour)},
expect: true, // Created 400 days ago, more than 365 days
},
// Edge case: future time (shouldn't normally happen, but test negative duration)
{
name: "future time - negative duration",
expr: "NOW - UpdatedAt < 1hour",
task: &task.Task{UpdatedAt: now.Add(1 * time.Hour)},
expect: true, // Future time results in negative duration, which is < 1hour
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, err := ParseFilter(tt.expr)
if err != nil {
t.Fatalf("ParseFilter failed: %v", err)
}
result := filter.Evaluate(tt.task, now, "testuser")
if result != tt.expect {
t.Errorf("Expected %v, got %v for expression: %s\nTask UpdatedAt: %v, CreatedAt: %v",
tt.expect, result, tt.expr, tt.task.UpdatedAt, tt.task.CreatedAt)
}
})
}
}
// TestTimeExpressionParsing tests that time expressions parse correctly
func TestTimeExpressionParsing(t *testing.T) {
tests := []struct {
name string
expr string
shouldError bool
}{
{
name: "valid NOW - UpdatedAt",
expr: "NOW - UpdatedAt < 24hours",
shouldError: false,
},
{
name: "valid NOW - CreatedAt",
expr: "NOW - CreatedAt > 1week",
shouldError: false,
},
{
name: "valid with minutes",
expr: "NOW - UpdatedAt < 30min",
shouldError: false,
},
{
name: "valid with days",
expr: "NOW - CreatedAt >= 7days",
shouldError: false,
},
{
name: "valid with months",
expr: "NOW - UpdatedAt < 2months",
shouldError: false,
},
{
name: "valid with seconds",
expr: "NOW - UpdatedAt < 30secs",
shouldError: false,
},
{
name: "valid with years",
expr: "NOW - CreatedAt > 1year",
shouldError: false,
},
{
name: "valid with parentheses",
expr: "(NOW - UpdatedAt < 1hour)",
shouldError: false,
},
{
name: "valid combined with AND",
expr: "NOW - UpdatedAt < 1day AND status = 'ready'",
shouldError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, err := ParseFilter(tt.expr)
if tt.shouldError && err == nil {
t.Error("Expected parsing error but got none")
}
if !tt.shouldError && err != nil {
t.Errorf("Expected no error but got: %v", err)
}
if !tt.shouldError && filter == nil {
t.Error("Expected filter but got nil")
}
})
}
}
// TestMultipleTimeConditions tests filters with multiple time-based conditions
func TestMultipleTimeConditions(t *testing.T) {
now := time.Now()
tests := []struct {
name string
expr string
task *task.Task
expect bool
}{
{
name: "both time conditions true",
expr: "NOW - CreatedAt > 7days AND NOW - UpdatedAt < 24hours",
task: &task.Task{
CreatedAt: now.Add(-10 * 24 * time.Hour),
UpdatedAt: now.Add(-12 * time.Hour),
},
expect: true,
},
{
name: "first time condition false",
expr: "NOW - CreatedAt > 7days AND NOW - UpdatedAt < 24hours",
task: &task.Task{
CreatedAt: now.Add(-3 * 24 * time.Hour),
UpdatedAt: now.Add(-12 * time.Hour),
},
expect: false,
},
{
name: "second time condition false",
expr: "NOW - CreatedAt > 7days AND NOW - UpdatedAt < 24hours",
task: &task.Task{
CreatedAt: now.Add(-10 * 24 * time.Hour),
UpdatedAt: now.Add(-48 * time.Hour),
},
expect: false,
},
{
name: "time conditions with OR",
expr: "NOW - UpdatedAt < 1hour OR NOW - CreatedAt < 1day",
task: &task.Task{
CreatedAt: now.Add(-10 * 24 * time.Hour),
UpdatedAt: now.Add(-30 * time.Minute),
},
expect: true, // Updated recently
},
{
name: "complex time expression with status",
expr: "(NOW - UpdatedAt < 2hours OR NOW - CreatedAt < 1day) AND status = 'ready'",
task: &task.Task{
CreatedAt: now.Add(-10 * 24 * time.Hour),
UpdatedAt: now.Add(-1 * time.Hour),
Status: task.StatusReady,
},
expect: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, err := ParseFilter(tt.expr)
if err != nil {
t.Fatalf("ParseFilter failed: %v", err)
}
result := filter.Evaluate(tt.task, now, "testuser")
if result != tt.expect {
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
}
})
}
}
func TestParseDuration_ParseError(t *testing.T) {
_, err := ParseDuration("abc")
if err == nil {
t.Fatal("expected error for non-numeric duration")
}
}
func TestParseDuration_UnknownUnit(t *testing.T) {
_, err := ParseDuration("10xyz")
if err == nil {
t.Fatal("expected error for unknown duration unit")
}
}

View file

@ -1,186 +0,0 @@
package filter
import (
"fmt"
"strings"
"unicode"
"github.com/boolean-maybe/tiki/util/parsing"
)
// TokenType represents the type of a lexer token
type TokenType int
const (
TokenEOF TokenType = iota
TokenIdent // field names like status, type, NOW, CURRENT_USER
TokenString // 'value' or "value"
TokenNumber // integer literals
TokenDuration // 24hour, 1week, etc.
TokenOperator // =, ==, !=, >, <, >=, <=, +, -
TokenAnd
TokenOr
TokenNot
TokenIn // IN keyword
TokenNotIn // NOT IN keyword combination
TokenLParen
TokenRParen
TokenLBracket // [ for list literals
TokenRBracket // ] for list literals
TokenComma // , for list elements
)
// Token represents a lexer token
type Token struct {
Type TokenType
Value string
}
// Multi-character operators mapped to their token types
var multiCharOps = map[string]TokenType{
"==": TokenOperator,
"!=": TokenOperator,
">=": TokenOperator,
"<=": TokenOperator,
}
// Keywords mapped to their token types
var keywords = map[string]TokenType{
"AND": TokenAnd,
"OR": TokenOr,
"NOT": TokenNot,
"IN": TokenIn,
}
// Time field names (uppercase)
var timeFields = map[string]bool{
"NOW": true,
"CREATEDAT": true,
"UPDATEDAT": true,
"DUE": true,
}
// isTimeField checks if a given identifier is a time field (case-insensitive)
func isTimeField(name string) bool {
return timeFields[strings.ToUpper(name)]
}
// Tokenize breaks the expression into tokens
func Tokenize(expr string) ([]Token, error) {
var tokens []Token
i := 0
for i < len(expr) {
// Skip whitespace
i = parsing.SkipWhitespace(expr, i)
if i >= len(expr) {
break
}
// Check for two-character operators first
if i+1 < len(expr) {
twoChar := expr[i : i+2]
if tokType, ok := multiCharOps[twoChar]; ok {
tokens = append(tokens, Token{Type: tokType, Value: twoChar})
i += 2
continue
}
}
// Single character tokens
switch expr[i] {
case '(':
tokens = append(tokens, Token{Type: TokenLParen, Value: "("})
i++
continue
case ')':
tokens = append(tokens, Token{Type: TokenRParen, Value: ")"})
i++
continue
case '[':
tokens = append(tokens, Token{Type: TokenLBracket, Value: "["})
i++
continue
case ']':
tokens = append(tokens, Token{Type: TokenRBracket, Value: "]"})
i++
continue
case ',':
tokens = append(tokens, Token{Type: TokenComma, Value: ","})
i++
continue
case '=', '>', '<', '+', '-':
tokens = append(tokens, Token{Type: TokenOperator, Value: string(expr[i])})
i++
continue
case '\'', '"':
// String literal
quote := expr[i]
i++
start := i
for i < len(expr) && expr[i] != quote {
i++
}
if i >= len(expr) {
return nil, fmt.Errorf("unterminated string literal")
}
tokens = append(tokens, Token{Type: TokenString, Value: expr[start:i]})
i++ // skip closing quote
continue
}
// Check for identifiers, keywords, numbers, and durations
if unicode.IsLetter(rune(expr[i])) || expr[i] == '_' {
word, newPos := parsing.ReadWhile(expr, i, func(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
})
i = newPos
wordUpper := strings.ToUpper(word)
// Check for "NOT IN" keyword combination
if wordUpper == "NOT" {
if matched, endPos := parsing.PeekKeyword(expr, i, "IN"); matched {
i = endPos
tokens = append(tokens, Token{Type: TokenNotIn, Value: "NOT IN"})
continue
}
}
// Check if it's a keyword
if tokType, ok := keywords[wordUpper]; ok {
tokens = append(tokens, Token{Type: tokType, Value: word})
} else {
tokens = append(tokens, Token{Type: TokenIdent, Value: word})
}
continue
}
// Check for numbers (which might be followed by duration unit)
if unicode.IsDigit(rune(expr[i])) {
numStr, newPos := parsing.ReadWhile(expr, i, unicode.IsDigit)
i = newPos
// Check if followed by duration unit
if i < len(expr) && unicode.IsLetter(rune(expr[i])) {
unitStr, unitEnd := parsing.ReadWhile(expr, i, unicode.IsLetter)
fullWord := numStr + unitStr
// Check if it's a valid duration
if IsDurationLiteral(fullWord) {
tokens = append(tokens, Token{Type: TokenDuration, Value: fullWord})
i = unitEnd
} else {
// Not a valid duration, just a number
tokens = append(tokens, Token{Type: TokenNumber, Value: numStr})
}
} else {
tokens = append(tokens, Token{Type: TokenNumber, Value: numStr})
}
continue
}
return nil, fmt.Errorf("unexpected character at position %d: %c", i, expr[i])
}
tokens = append(tokens, Token{Type: TokenEOF, Value: ""})
return tokens, nil
}

View file

@ -1,132 +0,0 @@
package filter
import "fmt"
// filterParser is a recursive descent parser for filter expressions
type filterParser struct {
tokens []Token
pos int
}
// newFilterParser creates a new parser with the given tokens
func newFilterParser(tokens []Token) *filterParser {
return &filterParser{tokens: tokens, pos: 0}
}
func (p *filterParser) current() Token {
if p.pos >= len(p.tokens) {
return Token{Type: TokenEOF}
}
return p.tokens[p.pos]
}
func (p *filterParser) advance() {
p.pos++
}
func (p *filterParser) expect(t TokenType) error {
tok := p.current()
if tok.Type != t {
return fmt.Errorf("expected token type %d, got %d (%s)", t, tok.Type, tok.Value)
}
p.advance()
return nil
}
// parseLeftAssociativeBinary parses left-associative binary operations
// like "a AND b AND c" -> ((a AND b) AND c)
func (p *filterParser) parseLeftAssociativeBinary(
operatorType TokenType,
operatorStr string,
subExprParser func() (FilterExpr, error),
) (FilterExpr, error) {
left, err := subExprParser()
if err != nil {
return nil, err
}
for p.current().Type == operatorType {
p.advance()
right, err := subExprParser()
if err != nil {
return nil, err
}
left = &BinaryExpr{Op: operatorStr, Left: left, Right: right}
}
return left, nil
}
// parseExpr parses: expr = orExpr
func (p *filterParser) parseExpr() (FilterExpr, error) {
return p.parseOrExpr()
}
// parseOrExpr parses: orExpr = andExpr (OR andExpr)*
func (p *filterParser) parseOrExpr() (FilterExpr, error) {
return p.parseLeftAssociativeBinary(TokenOr, "OR", p.parseAndExpr)
}
// parseAndExpr parses: andExpr = notExpr (AND notExpr)*
func (p *filterParser) parseAndExpr() (FilterExpr, error) {
return p.parseLeftAssociativeBinary(TokenAnd, "AND", p.parseNotExpr)
}
// parseNotExpr parses: notExpr = NOT notExpr | primaryExpr
func (p *filterParser) parseNotExpr() (FilterExpr, error) {
if p.current().Type == TokenNot {
p.advance()
expr, err := p.parseNotExpr()
if err != nil {
return nil, err
}
return &UnaryExpr{Op: "NOT", Expr: expr}, nil
}
return p.parsePrimaryExpr()
}
// parsePrimaryExpr parses: primaryExpr = '(' expr ')' | inExpr | comparison
func (p *filterParser) parsePrimaryExpr() (FilterExpr, error) {
if p.current().Type == TokenLParen {
p.advance()
expr, err := p.parseExpr()
if err != nil {
return nil, err
}
if err := p.expect(TokenRParen); err != nil {
return nil, fmt.Errorf("expected closing parenthesis: %w", err)
}
return expr, nil
}
// Try to parse as IN expression or regular comparison
// We need to look ahead to distinguish:
// field IN [...] -> InExpr
// field NOT IN [...] -> InExpr
// field = value -> CompareExpr
// Check if this starts with an identifier (field name)
if p.current().Type == TokenIdent {
fieldName := p.current().Value
p.advance()
// Check next token for IN or NOT IN
nextTok := p.current()
if nextTok.Type == TokenIn {
p.advance()
return p.parseInExpr(fieldName, false)
}
if nextTok.Type == TokenNotIn {
p.advance()
return p.parseInExpr(fieldName, true)
}
// Otherwise, backtrack and parse as regular comparison
p.pos--
return p.parseComparison()
}
// Not an identifier, try parsing as comparison
return p.parseComparison()
}

View file

@ -1,7 +0,0 @@
package filter
import "github.com/boolean-maybe/tiki/internal/teststatuses"
func init() {
teststatuses.Init()
}

View file

@ -1,51 +0,0 @@
package filter
import "fmt"
// ValidateFilterStatuses walks a FilterExpr AST and checks that any status
// references (in CompareExpr and InExpr nodes where Field == "status") are
// valid according to the provided validator function.
// Returns the first invalid status found with a descriptive error, or nil.
func ValidateFilterStatuses(expr FilterExpr, validStatus func(string) bool) error {
if expr == nil {
return nil
}
switch e := expr.(type) {
case *BinaryExpr:
if err := ValidateFilterStatuses(e.Left, validStatus); err != nil {
return err
}
return ValidateFilterStatuses(e.Right, validStatus)
case *UnaryExpr:
return ValidateFilterStatuses(e.Expr, validStatus)
case *CompareExpr:
if e.Field != "status" {
return nil
}
if strVal, ok := e.Value.(string); ok {
if !validStatus(strVal) {
return fmt.Errorf("filter references unknown status %q", strVal)
}
}
return nil
case *InExpr:
if e.Field != "status" {
return nil
}
for _, val := range e.Values {
if strVal, ok := val.(string); ok {
if !validStatus(strVal) {
return fmt.Errorf("filter references unknown status %q", strVal)
}
}
}
return nil
default:
return nil
}
}

View file

@ -1,89 +0,0 @@
package filter
import (
"testing"
)
func validStatus(key string) bool {
valid := map[string]bool{
"backlog": true, "ready": true, "inProgress": true, "review": true, "done": true,
}
return valid[key]
}
func TestValidateFilterStatuses_ValidCompare(t *testing.T) {
expr := &CompareExpr{Field: "status", Op: "=", Value: "ready"}
if err := ValidateFilterStatuses(expr, validStatus); err != nil {
t.Errorf("expected no error, got: %v", err)
}
}
func TestValidateFilterStatuses_InvalidCompare(t *testing.T) {
expr := &CompareExpr{Field: "status", Op: "=", Value: "bogus"}
err := ValidateFilterStatuses(expr, validStatus)
if err == nil {
t.Fatal("expected error for unknown status")
}
if got := err.Error(); got != `filter references unknown status "bogus"` {
t.Errorf("unexpected error message: %s", got)
}
}
func TestValidateFilterStatuses_ValidInExpr(t *testing.T) {
expr := &InExpr{Field: "status", Values: []interface{}{"ready", "done"}}
if err := ValidateFilterStatuses(expr, validStatus); err != nil {
t.Errorf("expected no error, got: %v", err)
}
}
func TestValidateFilterStatuses_InvalidInExpr(t *testing.T) {
expr := &InExpr{Field: "status", Values: []interface{}{"ready", "unknown"}}
err := ValidateFilterStatuses(expr, validStatus)
if err == nil {
t.Fatal("expected error for unknown status in IN list")
}
}
func TestValidateFilterStatuses_NonStatusField(t *testing.T) {
expr := &CompareExpr{Field: "type", Op: "=", Value: "anything"}
if err := ValidateFilterStatuses(expr, validStatus); err != nil {
t.Errorf("expected no error for non-status field, got: %v", err)
}
}
func TestValidateFilterStatuses_BinaryExpr(t *testing.T) {
expr := &BinaryExpr{
Op: "AND",
Left: &CompareExpr{Field: "status", Op: "=", Value: "ready"},
Right: &CompareExpr{Field: "status", Op: "!=", Value: "bogus"},
}
err := ValidateFilterStatuses(expr, validStatus)
if err == nil {
t.Fatal("expected error for unknown status in right branch")
}
}
func TestValidateFilterStatuses_UnaryExpr(t *testing.T) {
expr := &UnaryExpr{
Op: "NOT",
Expr: &CompareExpr{Field: "status", Op: "=", Value: "invalid"},
}
err := ValidateFilterStatuses(expr, validStatus)
if err == nil {
t.Fatal("expected error for unknown status in NOT expr")
}
}
func TestValidateFilterStatuses_Nil(t *testing.T) {
if err := ValidateFilterStatuses(nil, validStatus); err != nil {
t.Errorf("expected no error for nil expr, got: %v", err)
}
}
func TestValidateFilterStatuses_IntValue(t *testing.T) {
// Status compared with int value — should be ignored (only string values checked)
expr := &CompareExpr{Field: "status", Op: "=", Value: 42}
if err := ValidateFilterStatuses(expr, validStatus); err != nil {
t.Errorf("expected no error for non-string value, got: %v", err)
}
}

View file

@ -2,13 +2,20 @@ package plugin
import (
"testing"
"time"
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/task"
)
func TestPluginWithInFilter(t *testing.T) {
// Test loading a plugin definition with IN filter
func newTestExecutor() *ruki.Executor {
schema := rukiRuntime.NewSchema()
return ruki.NewExecutor(schema, func() string { return "testuser" },
ruki.ExecutorRuntime{Mode: ruki.ExecutorRuntimePlugin})
}
func TestPluginWithTagFilter(t *testing.T) {
schema := rukiRuntime.NewSchema()
pluginYAML := `
name: UI Tasks
foreground: "#ffffff"
@ -16,136 +23,119 @@ background: "#0000ff"
key: U
lanes:
- name: UI
filter: tags IN ['ui', 'ux', 'design']
filter: select where "ui" in tags or "ux" in tags or "design" in tags
`
def, err := parsePluginYAML([]byte(pluginYAML), "test")
def, err := parsePluginYAML([]byte(pluginYAML), "test", schema)
if err != nil {
t.Fatalf("Failed to parse plugin: %v", err)
t.Fatalf("failed to parse plugin: %v", err)
}
if def.GetName() != "UI Tasks" {
t.Errorf("Expected name 'UI Tasks', got '%s'", def.GetName())
t.Errorf("expected name 'UI Tasks', got '%s'", def.GetName())
}
tp, ok := def.(*TikiPlugin)
if !ok {
t.Fatalf("Expected TikiPlugin, got %T", def)
t.Fatalf("expected TikiPlugin, got %T", def)
}
if len(tp.Lanes) != 1 || tp.Lanes[0].Filter == nil {
t.Fatal("Expected lane filter to be parsed")
t.Fatal("expected lane filter to be parsed")
}
// Test filter evaluation with matching tasks
matchingTask := &task.Task{
ID: "TIKI-1",
Title: "Design mockups",
Tags: []string{"ui", "design"},
Status: task.StatusReady,
allTasks := []*task.Task{
{ID: "TIKI-000001", Title: "Design mockups", Tags: []string{"ui", "design"}, Status: task.StatusReady},
{ID: "TIKI-000002", Title: "Backend API", Tags: []string{"backend", "api"}, Status: task.StatusReady},
{ID: "TIKI-000003", Title: "UX Research", Tags: []string{"ux", "research"}, Status: task.StatusReady},
}
if !tp.Lanes[0].Filter.Evaluate(matchingTask, time.Now(), "testuser") {
t.Error("Expected filter to match task with 'ui' and 'design' tags")
executor := newTestExecutor()
result, err := executor.Execute(tp.Lanes[0].Filter, allTasks)
if err != nil {
t.Fatalf("executor error: %v", err)
}
// Test filter evaluation with non-matching tasks
nonMatchingTask := &task.Task{
ID: "TIKI-2",
Title: "Backend API",
Tags: []string{"backend", "api"},
Status: task.StatusReady,
filtered := result.Select.Tasks
if len(filtered) != 2 {
t.Fatalf("expected 2 matching tasks, got %d", len(filtered))
}
if tp.Lanes[0].Filter.Evaluate(nonMatchingTask, time.Now(), "testuser") {
t.Error("Expected filter to NOT match task with 'backend' and 'api' tags")
// task with "ui"+"design" and task with "ux" should match; "backend"+"api" should not
ids := map[string]bool{}
for _, tk := range filtered {
ids[tk.ID] = true
}
// Test with task that has one matching tag
partialMatchTask := &task.Task{
ID: "TIKI-3",
Title: "UX Research",
Tags: []string{"ux", "research"},
Status: task.StatusReady,
if !ids["TIKI-000001"] {
t.Error("expected task TIKI-000001 (ui, design tags) to match")
}
if !tp.Lanes[0].Filter.Evaluate(partialMatchTask, time.Now(), "testuser") {
t.Error("Expected filter to match task with 'ux' tag")
if ids["TIKI-000002"] {
t.Error("expected task TIKI-000002 (backend, api tags) to NOT match")
}
if !ids["TIKI-000003"] {
t.Error("expected task TIKI-000003 (ux tag) to match")
}
}
func TestPluginWithComplexInFilter(t *testing.T) {
// Test plugin with combined filters
func TestPluginWithComplexTagAndStatusFilter(t *testing.T) {
schema := rukiRuntime.NewSchema()
pluginYAML := `
name: Active Work
key: A
lanes:
- name: Active
filter: tags IN ['ui', 'backend'] AND status NOT IN ['done', 'backlog']
filter: select where ("ui" in tags or "backend" in tags) and status != "done" and status != "backlog"
`
def, err := parsePluginYAML([]byte(pluginYAML), "test")
def, err := parsePluginYAML([]byte(pluginYAML), "test", schema)
if err != nil {
t.Fatalf("Failed to parse plugin: %v", err)
t.Fatalf("failed to parse plugin: %v", err)
}
tp, ok := def.(*TikiPlugin)
if !ok {
t.Fatalf("Expected TikiPlugin, got %T", def)
t.Fatalf("expected TikiPlugin, got %T", def)
}
// Should match: has 'ui' tag and status is 'ready' (not done)
matchingTask := &task.Task{
ID: "TIKI-1",
Tags: []string{"ui", "frontend"},
Status: task.StatusReady,
allTasks := []*task.Task{
{ID: "TIKI-000001", Tags: []string{"ui", "frontend"}, Status: task.StatusReady},
{ID: "TIKI-000002", Tags: []string{"ui"}, Status: task.StatusDone},
{ID: "TIKI-000003", Tags: []string{"docs", "testing"}, Status: task.StatusInProgress},
}
if !tp.Lanes[0].Filter.Evaluate(matchingTask, time.Now(), "testuser") {
t.Error("Expected filter to match active UI task")
executor := newTestExecutor()
result, err := executor.Execute(tp.Lanes[0].Filter, allTasks)
if err != nil {
t.Fatalf("executor error: %v", err)
}
// Should NOT match: has 'ui' tag but status is 'done'
doneTask := &task.Task{
ID: "TIKI-2",
Tags: []string{"ui"},
Status: task.StatusDone,
filtered := result.Select.Tasks
if len(filtered) != 1 {
t.Fatalf("expected 1 matching task, got %d", len(filtered))
}
if tp.Lanes[0].Filter.Evaluate(doneTask, time.Now(), "testuser") {
t.Error("Expected filter to NOT match done UI task")
}
// Should NOT match: status is active but no matching tags
noTagsTask := &task.Task{
ID: "TIKI-3",
Tags: []string{"docs", "testing"},
Status: task.StatusInProgress,
}
if tp.Lanes[0].Filter.Evaluate(noTagsTask, time.Now(), "testuser") {
t.Error("Expected filter to NOT match task without matching tags")
if filtered[0].ID != "TIKI-000001" {
t.Errorf("expected TIKI-000001, got %s", filtered[0].ID)
}
}
func TestPluginWithStatusInFilter(t *testing.T) {
// Test plugin filtering by status
func TestPluginWithStatusFilter(t *testing.T) {
schema := rukiRuntime.NewSchema()
pluginYAML := `
name: In Progress Work
key: W
lanes:
- name: Active
filter: status IN ['ready', 'inProgress', 'inProgress']
filter: select where status = "ready" or status = "inProgress"
`
def, err := parsePluginYAML([]byte(pluginYAML), "test")
def, err := parsePluginYAML([]byte(pluginYAML), "test", schema)
if err != nil {
t.Fatalf("Failed to parse plugin: %v", err)
t.Fatalf("failed to parse plugin: %v", err)
}
tp, ok := def.(*TikiPlugin)
if !ok {
t.Fatalf("Expected TikiPlugin, got %T", def)
t.Fatalf("expected TikiPlugin, got %T", def)
}
testCases := []struct {
@ -153,23 +143,27 @@ lanes:
status task.Status
expect bool
}{
{"todo status", task.StatusReady, true},
{"ready status", task.StatusReady, true},
{"inProgress status", task.StatusInProgress, true},
{"blocked status", task.StatusInProgress, true},
{"done status", task.StatusDone, false},
{"review status", task.StatusReview, false},
}
executor := newTestExecutor()
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
task := &task.Task{
ID: "TIKI-1",
Status: tc.status,
allTasks := []*task.Task{
{ID: "TIKI-000001", Status: tc.status},
}
result := tp.Lanes[0].Filter.Evaluate(task, time.Now(), "testuser")
if result != tc.expect {
t.Errorf("Expected %v for status %s, got %v", tc.expect, tc.status, result)
result, err := executor.Execute(tp.Lanes[0].Filter, allTasks)
if err != nil {
t.Fatalf("executor error: %v", err)
}
got := len(result.Select.Tasks) > 0
if got != tc.expect {
t.Errorf("expected match=%v for status %s, got %v", tc.expect, tc.status, got)
}
})
}

View file

@ -7,6 +7,7 @@ import (
"strings"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/ruki"
"gopkg.in/yaml.v3"
)
@ -17,7 +18,7 @@ type WorkflowFile struct {
// loadPluginsFromFile loads plugins from a single workflow.yaml file.
// Returns the successfully loaded plugins and any validation errors encountered.
func loadPluginsFromFile(path string) ([]Plugin, []string) {
func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []string) {
data, err := os.ReadFile(path)
if err != nil {
slog.Warn("failed to read workflow.yaml", "path", path, "error", err)
@ -45,7 +46,7 @@ func loadPluginsFromFile(path string) ([]Plugin, []string) {
}
source := fmt.Sprintf("%s:%s", path, cfg.Name)
p, err := parsePluginConfig(cfg, source)
p, err := parsePluginConfig(cfg, source, schema)
if err != nil {
msg := fmt.Sprintf("%s: view %q: %v", path, cfg.Name, err)
slog.Warn("failed to load plugin from workflow.yaml", "name", cfg.Name, "error", err)
@ -107,7 +108,7 @@ func mergePluginLists(base, overrides []Plugin) []Plugin {
// Files are discovered via config.FindWorkflowFiles() which returns user config first, then project config.
// Plugins from later files override same-named plugins from earlier files via field merging.
// Returns an error when workflow files were found but no valid plugins could be loaded.
func LoadPlugins() ([]Plugin, error) {
func LoadPlugins(schema ruki.Schema) ([]Plugin, error) {
files := config.FindWorkflowFiles()
if len(files) == 0 {
slog.Debug("no workflow.yaml files found")
@ -117,12 +118,12 @@ func LoadPlugins() ([]Plugin, error) {
var allErrors []string
// First file is the base (typically user config)
base, errs := loadPluginsFromFile(files[0])
base, errs := loadPluginsFromFile(files[0], schema)
allErrors = append(allErrors, errs...)
// Remaining files are overrides, merged in order
for _, path := range files[1:] {
overrides, errs := loadPluginsFromFile(path)
overrides, errs := loadPluginsFromFile(path, schema)
allErrors = append(allErrors, errs...)
if len(overrides) > 0 {
base = mergePluginLists(base, overrides)

View file

@ -4,26 +4,24 @@ import (
"os"
"path/filepath"
"testing"
"time"
taskpkg "github.com/boolean-maybe/tiki/task"
)
func TestParsePluginConfig_FullyInline(t *testing.T) {
schema := testSchema()
cfg := pluginFileConfig{
Name: "Inline Test",
Foreground: "#ffffff",
Background: "#000000",
Key: "I",
Lanes: []PluginLaneConfig{
{Name: "Todo", Filter: "status = 'ready'"},
{Name: "Todo", Filter: `select where status = "ready"`},
},
Sort: "Priority DESC",
View: "expanded",
Default: true,
}
def, err := parsePluginConfig(cfg, "test")
def, err := parsePluginConfig(cfg, "test", schema)
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
@ -49,34 +47,24 @@ func TestParsePluginConfig_FullyInline(t *testing.T) {
t.Fatal("Expected lane filter to be parsed")
}
if len(tp.Sort) != 1 || tp.Sort[0].Field != "priority" || !tp.Sort[0].Descending {
t.Errorf("Expected sort 'Priority DESC', got %+v", tp.Sort)
if !tp.Lanes[0].Filter.IsSelect() {
t.Error("Expected lane filter to be a SELECT statement")
}
if !tp.IsDefault() {
t.Error("Expected IsDefault() to return true")
}
// test filter evaluation
task := &taskpkg.Task{
ID: "TIKI-1",
Status: taskpkg.StatusReady,
}
if !tp.Lanes[0].Filter.Evaluate(task, time.Now(), "testuser") {
t.Error("Expected filter to match todo task")
}
}
func TestParsePluginConfig_Minimal(t *testing.T) {
cfg := pluginFileConfig{
Name: "Minimal",
Lanes: []PluginLaneConfig{
{Name: "Bugs", Filter: "type = 'bug'"},
{Name: "Bugs", Filter: `select where type = "bug"`},
},
}
def, err := parsePluginConfig(cfg, "test")
def, err := parsePluginConfig(cfg, "test", testSchema())
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
@ -98,11 +86,11 @@ func TestParsePluginConfig_Minimal(t *testing.T) {
func TestParsePluginConfig_NoName(t *testing.T) {
cfg := pluginFileConfig{
Lanes: []PluginLaneConfig{
{Name: "Todo", Filter: "status = 'ready'"},
{Name: "Todo", Filter: `select where status = "ready"`},
},
}
_, err := parsePluginConfig(cfg, "test")
_, err := parsePluginConfig(cfg, "test", testSchema())
if err == nil {
t.Fatal("Expected error for plugin without name")
}
@ -117,7 +105,7 @@ func TestPluginTypeExplicit(t *testing.T) {
Text: "some text",
}
def, err := parsePluginConfig(cfg, "test")
def, err := parsePluginConfig(cfg, "test", testSchema())
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
@ -140,8 +128,7 @@ func TestLoadPluginsFromFile_WorkflowFile(t *testing.T) {
key: "F5"
lanes:
- name: Ready
filter: status = 'ready'
sort: Priority
filter: select where status = "ready"
- name: TestDocs
type: doki
fetcher: internal
@ -153,7 +140,7 @@ func TestLoadPluginsFromFile_WorkflowFile(t *testing.T) {
t.Fatalf("Failed to write workflow.yaml: %v", err)
}
plugins, errs := loadPluginsFromFile(workflowPath)
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
if len(errs) != 0 {
t.Fatalf("Expected no errors, got: %v", errs)
}
@ -187,7 +174,7 @@ func TestLoadPluginsFromFile_WorkflowFile(t *testing.T) {
func TestLoadPluginsFromFile_NoFile(t *testing.T) {
tmpDir := t.TempDir()
plugins, errs := loadPluginsFromFile(filepath.Join(tmpDir, "workflow.yaml"))
plugins, errs := loadPluginsFromFile(filepath.Join(tmpDir, "workflow.yaml"), testSchema())
if plugins != nil {
t.Errorf("Expected nil plugins when no workflow.yaml, got %d", len(plugins))
}
@ -203,7 +190,7 @@ func TestLoadPluginsFromFile_InvalidPlugin(t *testing.T) {
key: "V"
lanes:
- name: Todo
filter: status = 'ready'
filter: select where status = "ready"
- name: Invalid
type: unknown
`
@ -213,7 +200,7 @@ func TestLoadPluginsFromFile_InvalidPlugin(t *testing.T) {
}
// should load valid plugin and skip invalid one
plugins, errs := loadPluginsFromFile(workflowPath)
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
if len(plugins) != 1 {
t.Fatalf("Expected 1 valid plugin (invalid skipped), got %d", len(plugins))
}

View file

@ -12,7 +12,6 @@ type pluginFileConfig struct {
Background string `yaml:"background"`
Key string `yaml:"key"` // single character
Filter string `yaml:"filter"`
Sort string `yaml:"sort"`
View string `yaml:"view"` // "compact" or "expanded" (default: compact)
Type string `yaml:"type"` // "tiki" or "doki" (default: tiki)
Fetcher string `yaml:"fetcher"`
@ -46,7 +45,6 @@ func mergePluginDefinitions(base Plugin, override Plugin) Plugin {
Default: baseTiki.Default,
},
Lanes: baseTiki.Lanes,
Sort: baseTiki.Sort,
ViewMode: baseTiki.ViewMode,
Actions: baseTiki.Actions,
}
@ -69,9 +67,6 @@ func mergePluginDefinitions(base Plugin, override Plugin) Plugin {
if len(overrideTiki.Lanes) > 0 {
result.Lanes = overrideTiki.Lanes
}
if overrideTiki.Sort != nil {
result.Sort = overrideTiki.Sort
}
if overrideTiki.ViewMode != "" {
result.ViewMode = overrideTiki.ViewMode
}

View file

@ -5,12 +5,24 @@ import (
"github.com/gdamore/tcell/v2"
"github.com/boolean-maybe/tiki/plugin/filter"
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
"github.com/boolean-maybe/tiki/ruki"
)
// mustParseFilter is a test helper that parses a ruki select statement or panics.
func mustParseFilter(t *testing.T, expr string) *ruki.ValidatedStatement {
t.Helper()
schema := rukiRuntime.NewSchema()
parser := ruki.NewParser(schema)
stmt, err := parser.ParseAndValidateStatement(expr, ruki.ExecutorRuntimePlugin)
if err != nil {
t.Fatalf("failed to parse ruki statement %q: %v", expr, err)
}
return stmt
}
func TestMergePluginDefinitions_TikiToTiki(t *testing.T) {
baseFilter, _ := filter.ParseFilter("status = 'ready'")
baseSort, _ := ParseSort("Priority")
baseFilter := mustParseFilter(t, `select where status = "ready"`)
base := &TikiPlugin{
BasePlugin: BasePlugin{
@ -25,11 +37,10 @@ func TestMergePluginDefinitions_TikiToTiki(t *testing.T) {
Lanes: []TikiLane{
{Name: "Todo", Columns: 1, Filter: baseFilter},
},
Sort: baseSort,
ViewMode: "compact",
}
overrideFilter, _ := filter.ParseFilter("type = 'bug'")
overrideFilter := mustParseFilter(t, `select where type = "bug"`)
override := &TikiPlugin{
BasePlugin: BasePlugin{
Name: "Base",
@ -45,49 +56,42 @@ func TestMergePluginDefinitions_TikiToTiki(t *testing.T) {
Lanes: []TikiLane{
{Name: "Bugs", Columns: 1, Filter: overrideFilter},
},
Sort: nil,
ViewMode: "expanded",
}
result := mergePluginDefinitions(base, override)
resultTiki, ok := result.(*TikiPlugin)
if !ok {
t.Fatal("Expected result to be *TikiPlugin")
t.Fatal("expected result to be *TikiPlugin")
}
// Check overridden values
// check overridden values
if resultTiki.Rune != 'O' {
t.Errorf("Expected rune 'O', got %q", resultTiki.Rune)
t.Errorf("expected rune 'O', got %q", resultTiki.Rune)
}
if resultTiki.Modifier != tcell.ModAlt {
t.Errorf("Expected ModAlt, got %v", resultTiki.Modifier)
t.Errorf("expected ModAlt, got %v", resultTiki.Modifier)
}
if resultTiki.Foreground != tcell.ColorGreen {
t.Errorf("Expected green foreground, got %v", resultTiki.Foreground)
t.Errorf("expected green foreground, got %v", resultTiki.Foreground)
}
if resultTiki.ViewMode != "expanded" {
t.Errorf("Expected expanded view, got %q", resultTiki.ViewMode)
t.Errorf("expected expanded view, got %q", resultTiki.ViewMode)
}
if len(resultTiki.Lanes) != 1 || resultTiki.Lanes[0].Filter == nil {
t.Error("Expected lane filter to be overridden")
}
// Check that base sort is kept when override has nil
if resultTiki.Sort == nil {
t.Error("Expected base sort to be retained")
t.Error("expected lane filter to be overridden")
}
}
func TestMergePluginDefinitions_PreservesModifier(t *testing.T) {
// This test verifies the bug fix where Modifier was not being copied from base
baseFilter, _ := filter.ParseFilter("status = 'ready'")
baseFilter := mustParseFilter(t, `select where status = "ready"`)
base := &TikiPlugin{
BasePlugin: BasePlugin{
Name: "Base",
Key: tcell.KeyRune,
Rune: 'M',
Modifier: tcell.ModAlt, // This should be preserved
Modifier: tcell.ModAlt, // this should be preserved
Foreground: tcell.ColorWhite,
Background: tcell.ColorDefault,
Type: "tiki",
@ -97,7 +101,7 @@ func TestMergePluginDefinitions_PreservesModifier(t *testing.T) {
},
}
// Override with no modifier change (Modifier: 0)
// override with no modifier change (Modifier: 0)
override := &TikiPlugin{
BasePlugin: BasePlugin{
Name: "Base",
@ -110,14 +114,14 @@ func TestMergePluginDefinitions_PreservesModifier(t *testing.T) {
result := mergePluginDefinitions(base, override)
resultTiki, ok := result.(*TikiPlugin)
if !ok {
t.Fatal("Expected result to be *TikiPlugin")
t.Fatal("expected result to be *TikiPlugin")
}
// The Modifier from base should be preserved
// the Modifier from base should be preserved
if resultTiki.Modifier != tcell.ModAlt {
t.Errorf("Expected ModAlt to be preserved from base, got %v", resultTiki.Modifier)
t.Errorf("expected ModAlt to be preserved from base, got %v", resultTiki.Modifier)
}
if resultTiki.Rune != 'M' {
t.Errorf("Expected rune 'M' to be preserved from base, got %q", resultTiki.Rune)
t.Errorf("expected rune 'M' to be preserved from base, got %q", resultTiki.Rune)
}
}

View file

@ -9,12 +9,11 @@ import (
"github.com/gdamore/tcell/v2"
"gopkg.in/yaml.v3"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/plugin/filter"
"github.com/boolean-maybe/tiki/ruki"
)
// parsePluginConfig parses a pluginFileConfig into a Plugin
func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
func parsePluginConfig(cfg pluginFileConfig, source string, schema ruki.Schema) (Plugin, error) {
if cfg.Name == "" {
return nil, fmt.Errorf("plugin must have a name (%s)", source)
}
@ -54,9 +53,6 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
if cfg.Filter != "" {
return nil, fmt.Errorf("doki plugin cannot have 'filter'")
}
if cfg.Sort != "" {
return nil, fmt.Errorf("doki plugin cannot have 'sort'")
}
if cfg.View != "" {
return nil, fmt.Errorf("doki plugin cannot have 'view'")
}
@ -105,6 +101,8 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
return nil, fmt.Errorf("tiki plugin has too many lanes (%d), max is 10", len(cfg.Lanes))
}
parser := ruki.NewParser(schema)
lanes := make([]TikiLane, 0, len(cfg.Lanes))
for i, lane := range cfg.Lanes {
if lane.Name == "" {
@ -120,26 +118,35 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
if lane.Width < 0 || lane.Width > 100 {
return nil, fmt.Errorf("lane %q has invalid width %d (must be 0-100)", lane.Name, lane.Width)
}
filterExpr, err := filter.ParseFilter(lane.Filter)
if err != nil {
return nil, fmt.Errorf("parsing filter for lane %q: %w", lane.Name, err)
}
if filterExpr != nil {
reg := config.GetStatusRegistry()
if err := filter.ValidateFilterStatuses(filterExpr, reg.IsValid); err != nil {
return nil, fmt.Errorf("view %q, lane %q: %w", cfg.Name, lane.Name, err)
var filterStmt *ruki.ValidatedStatement
if lane.Filter != "" {
filterStmt, err = parser.ParseAndValidateStatement(lane.Filter, ruki.ExecutorRuntimePlugin)
if err != nil {
return nil, fmt.Errorf("parsing filter for lane %q: %w", lane.Name, err)
}
if !filterStmt.IsSelect() {
return nil, fmt.Errorf("lane %q filter must be a SELECT statement", lane.Name)
}
}
action, err := ParseLaneAction(lane.Action)
if err != nil {
return nil, fmt.Errorf("parsing action for lane %q: %w", lane.Name, err)
var actionStmt *ruki.ValidatedStatement
if lane.Action != "" {
actionStmt, err = parser.ParseAndValidateStatement(lane.Action, ruki.ExecutorRuntimePlugin)
if err != nil {
return nil, fmt.Errorf("parsing action for lane %q: %w", lane.Name, err)
}
if !actionStmt.IsUpdate() {
return nil, fmt.Errorf("lane %q action must be an UPDATE statement", lane.Name)
}
}
lanes = append(lanes, TikiLane{
Name: lane.Name,
Columns: columns,
Width: lane.Width,
Filter: filterExpr,
Action: action,
Filter: filterStmt,
Action: actionStmt,
})
}
@ -152,14 +159,8 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
slog.Warn("lane widths sum exceeds 100%", "plugin", cfg.Name, "sum", widthSum)
}
// Parse sort rules
sortRules, err := ParseSort(cfg.Sort)
if err != nil {
return nil, fmt.Errorf("parsing sort: %w", err)
}
// Parse plugin actions
actions, err := parsePluginActions(cfg.Actions)
actions, err := parsePluginActions(cfg.Actions, parser)
if err != nil {
return nil, fmt.Errorf("plugin %q (%s): %w", cfg.Name, source, err)
}
@ -167,7 +168,6 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
return &TikiPlugin{
BasePlugin: base,
Lanes: lanes,
Sort: sortRules,
ViewMode: cfg.View,
Actions: actions,
}, nil
@ -178,7 +178,7 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
}
// parsePluginActions parses and validates plugin action configs into PluginAction slice.
func parsePluginActions(configs []PluginActionConfig) ([]PluginAction, error) {
func parsePluginActions(configs []PluginActionConfig, parser *ruki.Parser) ([]PluginAction, error) {
if len(configs) == 0 {
return nil, nil
}
@ -212,18 +212,18 @@ func parsePluginActions(configs []PluginActionConfig) ([]PluginAction, error) {
return nil, fmt.Errorf("action %d (key %q) missing 'action'", i, cfg.Key)
}
action, err := ParseLaneAction(cfg.Action)
actionStmt, err := parser.ParseAndValidateStatement(cfg.Action, ruki.ExecutorRuntimePlugin)
if err != nil {
return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err)
}
if len(action.Ops) == 0 {
return nil, fmt.Errorf("action %d (key %q) has empty action expression", i, cfg.Key)
if actionStmt.IsSelect() {
return nil, fmt.Errorf("action %d (key %q) must be UPDATE, CREATE, or DELETE — not SELECT", i, cfg.Key)
}
actions = append(actions, PluginAction{
Rune: r,
Label: cfg.Label,
Action: action,
Action: actionStmt,
})
}
@ -231,11 +231,11 @@ func parsePluginActions(configs []PluginActionConfig) ([]PluginAction, error) {
}
// parsePluginYAML parses plugin YAML data into a Plugin
func parsePluginYAML(data []byte, source string) (Plugin, error) {
func parsePluginYAML(data []byte, source string, schema ruki.Schema) (Plugin, error) {
var cfg pluginFileConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing yaml: %w", err)
}
return parsePluginConfig(cfg, source)
return parsePluginConfig(cfg, source, schema)
}

View file

@ -3,9 +3,22 @@ package plugin
import (
"strings"
"testing"
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
"github.com/boolean-maybe/tiki/ruki"
)
func testSchema() ruki.Schema {
return rukiRuntime.NewSchema()
}
func testParser() *ruki.Parser {
return ruki.NewParser(testSchema())
}
func TestDokiValidation(t *testing.T) {
schema := testSchema()
tests := []struct {
name string
cfg pluginFileConfig
@ -81,7 +94,7 @@ func TestDokiValidation(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := parsePluginConfig(tc.cfg, "test")
_, err := parsePluginConfig(tc.cfg, "test", schema)
if tc.wantError != "" {
if err == nil {
t.Errorf("Expected error containing '%s', got nil", tc.wantError)
@ -98,6 +111,8 @@ func TestDokiValidation(t *testing.T) {
}
func TestTikiValidation(t *testing.T) {
schema := testSchema()
tests := []struct {
name string
cfg pluginFileConfig
@ -108,18 +123,22 @@ func TestTikiValidation(t *testing.T) {
cfg: pluginFileConfig{
Name: "Tiki with Fetcher",
Type: "tiki",
Filter: "status='ready'",
Fetcher: "file",
Lanes: []PluginLaneConfig{
{Name: "Todo", Filter: `select where status = "ready"`},
},
},
wantError: "tiki plugin cannot have 'fetcher'",
},
{
name: "Tiki with Doki fields (Text)",
cfg: pluginFileConfig{
Name: "Tiki with Text",
Type: "tiki",
Filter: "status='ready'",
Text: "text",
Name: "Tiki with Text",
Type: "tiki",
Text: "text",
Lanes: []PluginLaneConfig{
{Name: "Todo", Filter: `select where status = "ready"`},
},
},
wantError: "tiki plugin cannot have 'text'",
},
@ -127,7 +146,7 @@ func TestTikiValidation(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := parsePluginConfig(tc.cfg, "test")
_, err := parsePluginConfig(tc.cfg, "test", schema)
if tc.wantError != "" {
if err == nil {
t.Errorf("Expected error containing '%s', got nil", tc.wantError)
@ -150,7 +169,7 @@ func TestParsePluginConfig_InvalidKey(t *testing.T) {
Type: "tiki",
}
_, err := parsePluginConfig(cfg, "test.yaml")
_, err := parsePluginConfig(cfg, "test.yaml", testSchema())
if err == nil {
t.Fatal("Expected error for invalid key format")
}
@ -165,12 +184,12 @@ func TestParsePluginConfig_DefaultTikiType(t *testing.T) {
Name: "Test",
Key: "T",
Lanes: []PluginLaneConfig{
{Name: "Todo", Filter: "status='ready'"},
{Name: "Todo", Filter: `select where status = "ready"`},
},
// Type not specified, should default to "tiki"
}
plugin, err := parsePluginConfig(cfg, "test.yaml")
plugin, err := parsePluginConfig(cfg, "test.yaml", testSchema())
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
@ -187,7 +206,7 @@ func TestParsePluginConfig_UnknownType(t *testing.T) {
Type: "unknown",
}
_, err := parsePluginConfig(cfg, "test.yaml")
_, err := parsePluginConfig(cfg, "test.yaml", testSchema())
if err == nil {
t.Fatal("Expected error for unknown plugin type")
}
@ -206,7 +225,7 @@ func TestParsePluginConfig_TikiWithInvalidFilter(t *testing.T) {
Filter: "invalid ( filter",
}
_, err := parsePluginConfig(cfg, "test.yaml")
_, err := parsePluginConfig(cfg, "test.yaml", testSchema())
if err == nil {
t.Fatal("Expected error for invalid top-level filter")
}
@ -219,26 +238,6 @@ func TestParsePluginConfig_TikiWithInvalidFilter(t *testing.T) {
// TestParsePluginConfig_TikiWithInvalidSort removed - the sort parser is very lenient
// and accepts most field names. Invalid syntax would be caught by ParseSort internally.
func TestParsePluginConfig_DokiWithSort(t *testing.T) {
cfg := pluginFileConfig{
Name: "Test",
Key: "T",
Type: "doki",
Fetcher: "internal",
Text: "content",
Sort: "Priority", // Doki shouldn't have sort
}
_, err := parsePluginConfig(cfg, "test.yaml")
if err == nil {
t.Fatal("Expected error for doki with sort field")
}
if !strings.Contains(err.Error(), "doki plugin cannot have 'sort'") {
t.Errorf("Expected 'cannot have sort' error, got: %v", err)
}
}
func TestParsePluginConfig_DokiWithView(t *testing.T) {
cfg := pluginFileConfig{
Name: "Test",
@ -249,7 +248,7 @@ func TestParsePluginConfig_DokiWithView(t *testing.T) {
View: "expanded", // Doki shouldn't have view
}
_, err := parsePluginConfig(cfg, "test.yaml")
_, err := parsePluginConfig(cfg, "test.yaml", testSchema())
if err == nil {
t.Fatal("Expected error for doki with view field")
}
@ -262,7 +261,7 @@ func TestParsePluginConfig_DokiWithView(t *testing.T) {
func TestParsePluginYAML_InvalidYAML(t *testing.T) {
invalidYAML := []byte("invalid: yaml: content:")
_, err := parsePluginYAML(invalidYAML, "test.yaml")
_, err := parsePluginYAML(invalidYAML, "test.yaml", testSchema())
if err == nil {
t.Fatal("Expected error for invalid YAML")
}
@ -280,14 +279,13 @@ type: tiki
lanes:
- name: Todo
columns: 4
filter: status = 'ready'
sort: Priority
filter: select where status = "ready"
view: expanded
foreground: "#ff0000"
background: "#0000ff"
`)
plugin, err := parsePluginYAML(validYAML, "test.yaml")
plugin, err := parsePluginYAML(validYAML, "test.yaml", testSchema())
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
@ -315,12 +313,14 @@ background: "#0000ff"
}
func TestParsePluginActions_Valid(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "b", Label: "Add to board", Action: "status = 'ready'"},
{Key: "a", Label: "Assign to me", Action: "assignee = CURRENT_USER"},
{Key: "b", Label: "Add to board", Action: `update where id = id() set status="ready"`},
{Key: "a", Label: "Assign to me", Action: `update where id = id() set assignee=user()`},
}
actions, err := parsePluginActions(configs)
actions, err := parsePluginActions(configs, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -335,23 +335,28 @@ func TestParsePluginActions_Valid(t *testing.T) {
if actions[0].Label != "Add to board" {
t.Errorf("expected label 'Add to board', got %q", actions[0].Label)
}
if len(actions[0].Action.Ops) != 1 {
t.Fatalf("expected 1 op, got %d", len(actions[0].Action.Ops))
if actions[0].Action == nil {
t.Fatal("expected non-nil action")
}
if actions[0].Action.Ops[0].Field != ActionFieldStatus {
t.Errorf("expected status field, got %v", actions[0].Action.Ops[0].Field)
}
if actions[0].Action.Ops[0].StrValue != "ready" {
t.Errorf("expected 'ready', got %q", actions[0].Action.Ops[0].StrValue)
if !actions[0].Action.IsUpdate() {
t.Error("expected action to be an UPDATE statement")
}
if actions[1].Rune != 'a' {
t.Errorf("expected rune 'a', got %q", actions[1].Rune)
}
if actions[1].Action == nil {
t.Fatal("expected non-nil action for 'assign to me'")
}
if !actions[1].Action.IsUpdate() {
t.Error("expected 'assign to me' action to be an UPDATE statement")
}
}
func TestParsePluginActions_Empty(t *testing.T) {
actions, err := parsePluginActions(nil)
parser := testParser()
actions, err := parsePluginActions(nil, parser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -361,6 +366,8 @@ func TestParsePluginActions_Empty(t *testing.T) {
}
func TestParsePluginActions_Errors(t *testing.T) {
parser := testParser()
tests := []struct {
name string
configs []PluginActionConfig
@ -368,17 +375,17 @@ func TestParsePluginActions_Errors(t *testing.T) {
}{
{
name: "missing key",
configs: []PluginActionConfig{{Key: "", Label: "Test", Action: "status=ready"}},
configs: []PluginActionConfig{{Key: "", Label: "Test", Action: `update where id = id() set status="ready"`}},
wantErr: "missing 'key'",
},
{
name: "multi-character key",
configs: []PluginActionConfig{{Key: "ab", Label: "Test", Action: "status=ready"}},
configs: []PluginActionConfig{{Key: "ab", Label: "Test", Action: `update where id = id() set status="ready"`}},
wantErr: "single character",
},
{
name: "missing label",
configs: []PluginActionConfig{{Key: "b", Label: "", Action: "status=ready"}},
configs: []PluginActionConfig{{Key: "b", Label: "", Action: `update where id = id() set status="ready"`}},
wantErr: "missing 'label'",
},
{
@ -388,14 +395,14 @@ func TestParsePluginActions_Errors(t *testing.T) {
},
{
name: "invalid action expression",
configs: []PluginActionConfig{{Key: "b", Label: "Test", Action: "owner=me"}},
wantErr: "unknown action field",
configs: []PluginActionConfig{{Key: "b", Label: "Test", Action: `update where id = id() set owner="me"`}},
wantErr: "parsing action",
},
{
name: "duplicate key",
configs: []PluginActionConfig{
{Key: "b", Label: "First", Action: "status=ready"},
{Key: "b", Label: "Second", Action: "status=done"},
{Key: "b", Label: "First", Action: `update where id = id() set status="ready"`},
{Key: "b", Label: "Second", Action: `update where id = id() set status="done"`},
},
wantErr: "duplicate action key",
},
@ -404,7 +411,7 @@ func TestParsePluginActions_Errors(t *testing.T) {
configs: func() []PluginActionConfig {
configs := make([]PluginActionConfig, 11)
for i := range configs {
configs[i] = PluginActionConfig{Key: string(rune('a' + i)), Label: "Test", Action: "status=ready"}
configs[i] = PluginActionConfig{Key: string(rune('a' + i)), Label: "Test", Action: `update where id = id() set status="ready"`}
}
return configs
}(),
@ -414,7 +421,7 @@ func TestParsePluginActions_Errors(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := parsePluginActions(tc.configs)
_, err := parsePluginActions(tc.configs, parser)
if err == nil {
t.Fatalf("expected error containing %q", tc.wantErr)
}
@ -431,15 +438,14 @@ name: Test
key: T
lanes:
- name: Backlog
filter: status = 'backlog'
filter: select where status = "backlog"
actions:
- key: "b"
label: "Add to board"
action: status = 'ready'
sort: Priority
action: update where id = id() set status = "ready"
`)
p, err := parsePluginYAML(yamlData, "test.yaml")
p, err := parsePluginYAML(yamlData, "test.yaml", testSchema())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -469,11 +475,11 @@ func TestParsePluginConfig_DokiWithActions(t *testing.T) {
Fetcher: "internal",
Text: "content",
Actions: []PluginActionConfig{
{Key: "b", Label: "Test", Action: "status=ready"},
{Key: "b", Label: "Test", Action: `update where id = id() set status="ready"`},
},
}
_, err := parsePluginConfig(cfg, "test.yaml")
_, err := parsePluginConfig(cfg, "test.yaml", testSchema())
if err == nil {
t.Fatal("expected error for doki with actions")
}
@ -492,7 +498,7 @@ url: http://example.com/doc
foreground: "#00ff00"
`)
plugin, err := parsePluginYAML(validYAML, "test.yaml")
plugin, err := parsePluginYAML(validYAML, "test.yaml", testSchema())
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}

View file

@ -1,134 +0,0 @@
package plugin
import (
"sort"
"strings"
"github.com/boolean-maybe/tiki/task"
)
// SortRule represents a single sort criterion
type SortRule struct {
Field string // "Assignee", "Points", "Priority", "CreatedAt", "UpdatedAt", "Status", "Type", "Title"
Descending bool // true for DESC, false for ASC (default)
}
// ParseSort parses a sort expression like "Assignee, Points DESC, Priority DESC, CreatedAt"
func ParseSort(expr string) ([]SortRule, error) {
expr = strings.TrimSpace(expr)
if expr == "" {
return nil, nil // no custom sorting
}
var rules []SortRule
parts := strings.Split(expr, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
fields := strings.Fields(part)
if len(fields) == 0 {
continue
}
rule := SortRule{
Field: normalizeFieldName(fields[0]),
Descending: false,
}
if len(fields) > 1 && strings.ToUpper(fields[1]) == "DESC" {
rule.Descending = true
}
rules = append(rules, rule)
}
return rules, nil
}
// normalizeFieldName normalizes field names to a canonical form
func normalizeFieldName(field string) string {
switch strings.ToLower(field) {
case "assignee":
return "assignee"
case "points":
return "points"
case "priority":
return "priority"
case "createdat":
return "createdat"
case "updatedat":
return "updatedat"
case "status":
return "status"
case "type":
return "type"
case "title":
return "title"
case "id":
return "id"
default:
return strings.ToLower(field)
}
}
// SortTasks sorts tasks according to the given sort rules
func SortTasks(tasks []*task.Task, rules []SortRule) {
if len(rules) == 0 {
return // preserve original order
}
sort.SliceStable(tasks, func(i, j int) bool {
for _, rule := range rules {
cmp := compareByField(tasks[i], tasks[j], rule.Field)
if cmp != 0 {
if rule.Descending {
return cmp > 0
}
return cmp < 0
}
}
return false // equal by all criteria, preserve order
})
}
// compareByField compares two tasks by a specific field
// Returns negative if a < b, 0 if equal, positive if a > b
func compareByField(a, b *task.Task, field string) int {
switch field {
case "assignee":
return strings.Compare(strings.ToLower(a.Assignee), strings.ToLower(b.Assignee))
case "points":
return a.Points - b.Points
case "priority":
return a.Priority - b.Priority
case "createdat":
if a.CreatedAt.Before(b.CreatedAt) {
return -1
} else if a.CreatedAt.After(b.CreatedAt) {
return 1
}
return 0
case "updatedat":
if a.UpdatedAt.Before(b.UpdatedAt) {
return -1
} else if a.UpdatedAt.After(b.UpdatedAt) {
return 1
}
return 0
case "status":
return strings.Compare(string(a.Status), string(b.Status))
case "type":
return strings.Compare(string(a.Type), string(b.Type))
case "title":
return strings.Compare(strings.ToLower(a.Title), strings.ToLower(b.Title))
case "id":
// Sort IDs lexicographically (alphanumeric IDs)
return strings.Compare(strings.ToLower(a.ID), strings.ToLower(b.ID))
default:
return 0
}
}

View file

@ -1,171 +0,0 @@
package plugin
import (
"testing"
"time"
"github.com/boolean-maybe/tiki/task"
)
func TestSortTasks_NoRules(t *testing.T) {
tasks := []*task.Task{
{ID: "TIKI-C", Priority: 3},
{ID: "TIKI-A", Priority: 1},
{ID: "TIKI-B", Priority: 2},
}
SortTasks(tasks, nil)
// original order preserved
if tasks[0].ID != "TIKI-C" || tasks[1].ID != "TIKI-A" || tasks[2].ID != "TIKI-B" {
t.Errorf("expected original order preserved, got %v %v %v", tasks[0].ID, tasks[1].ID, tasks[2].ID)
}
}
func TestSortTasks_ByField(t *testing.T) {
now := time.Now()
earlier := now.Add(-time.Hour)
later := now.Add(time.Hour)
tests := []struct {
name string
tasks []*task.Task
rules []SortRule
expectedID []string
}{
{
name: "priority ASC",
tasks: []*task.Task{
{ID: "TIKI-C", Priority: 3},
{ID: "TIKI-A", Priority: 1},
{ID: "TIKI-B", Priority: 2},
},
rules: []SortRule{{Field: "priority", Descending: false}},
expectedID: []string{"TIKI-A", "TIKI-B", "TIKI-C"},
},
{
name: "priority DESC",
tasks: []*task.Task{
{ID: "TIKI-A", Priority: 1},
{ID: "TIKI-B", Priority: 2},
{ID: "TIKI-C", Priority: 3},
},
rules: []SortRule{{Field: "priority", Descending: true}},
expectedID: []string{"TIKI-C", "TIKI-B", "TIKI-A"},
},
{
name: "title ASC case-insensitive",
tasks: []*task.Task{
{ID: "TIKI-Z", Title: "Zebra"},
{ID: "TIKI-A", Title: "apple"},
{ID: "TIKI-M", Title: "Mango"},
},
rules: []SortRule{{Field: "title", Descending: false}},
expectedID: []string{"TIKI-A", "TIKI-M", "TIKI-Z"},
},
{
name: "points ASC",
tasks: []*task.Task{
{ID: "TIKI-H", Points: 8},
{ID: "TIKI-L", Points: 1},
{ID: "TIKI-M", Points: 5},
},
rules: []SortRule{{Field: "points", Descending: false}},
expectedID: []string{"TIKI-L", "TIKI-M", "TIKI-H"},
},
{
name: "assignee ASC",
tasks: []*task.Task{
{ID: "TIKI-Z", Assignee: "Zara"},
{ID: "TIKI-A", Assignee: "alice"},
{ID: "TIKI-M", Assignee: "Bob"},
},
rules: []SortRule{{Field: "assignee", Descending: false}},
expectedID: []string{"TIKI-A", "TIKI-M", "TIKI-Z"},
},
{
name: "status ASC",
tasks: []*task.Task{
{ID: "TIKI-R", Status: "ready"},
{ID: "TIKI-B", Status: "backlog"},
{ID: "TIKI-D", Status: "done"},
},
rules: []SortRule{{Field: "status", Descending: false}},
expectedID: []string{"TIKI-B", "TIKI-D", "TIKI-R"},
},
{
name: "type ASC",
tasks: []*task.Task{
{ID: "TIKI-S", Type: task.TypeStory},
{ID: "TIKI-B", Type: task.TypeBug},
},
rules: []SortRule{{Field: "type", Descending: false}},
expectedID: []string{"TIKI-B", "TIKI-S"},
},
{
name: "id ASC",
tasks: []*task.Task{
{ID: "TIKI-C"},
{ID: "TIKI-A"},
{ID: "TIKI-B"},
},
rules: []SortRule{{Field: "id", Descending: false}},
expectedID: []string{"TIKI-A", "TIKI-B", "TIKI-C"},
},
{
name: "createdat ASC",
tasks: []*task.Task{
{ID: "TIKI-L", CreatedAt: later},
{ID: "TIKI-E", CreatedAt: earlier},
{ID: "TIKI-N", CreatedAt: now},
},
rules: []SortRule{{Field: "createdat", Descending: false}},
expectedID: []string{"TIKI-E", "TIKI-N", "TIKI-L"},
},
{
name: "updatedat DESC",
tasks: []*task.Task{
{ID: "TIKI-E", UpdatedAt: earlier},
{ID: "TIKI-L", UpdatedAt: later},
{ID: "TIKI-N", UpdatedAt: now},
},
rules: []SortRule{{Field: "updatedat", Descending: true}},
expectedID: []string{"TIKI-L", "TIKI-N", "TIKI-E"},
},
{
name: "multi-rule: priority ASC then title ASC",
tasks: []*task.Task{
{ID: "TIKI-B2", Priority: 2, Title: "Beta"},
{ID: "TIKI-A2", Priority: 2, Title: "Alpha"},
{ID: "TIKI-A1", Priority: 1, Title: "Zeta"},
},
rules: []SortRule{
{Field: "priority", Descending: false},
{Field: "title", Descending: false},
},
expectedID: []string{"TIKI-A1", "TIKI-A2", "TIKI-B2"},
},
{
name: "unknown field — equal comparison, stable order preserved",
tasks: []*task.Task{
{ID: "TIKI-X"},
{ID: "TIKI-Y"},
},
rules: []SortRule{{Field: "nonexistent", Descending: false}},
expectedID: []string{"TIKI-X", "TIKI-Y"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
SortTasks(tt.tasks, tt.rules)
if len(tt.tasks) != len(tt.expectedID) {
t.Fatalf("task count = %d, want %d", len(tt.tasks), len(tt.expectedID))
}
for i, want := range tt.expectedID {
if tt.tasks[i].ID != want {
t.Errorf("tasks[%d].ID = %q, want %q", i, tt.tasks[i].ID, want)
}
}
})
}
}

View file

@ -34,6 +34,19 @@ func (v *ValidatedStatement) RequiresCreateTemplate() bool {
return v != nil && v.statement != nil && v.statement.Create != nil
}
func (v *ValidatedStatement) IsSelect() bool {
return v != nil && v.statement != nil && v.statement.Select != nil
}
func (v *ValidatedStatement) IsUpdate() bool {
return v != nil && v.statement != nil && v.statement.Update != nil
}
func (v *ValidatedStatement) IsCreate() bool {
return v != nil && v.statement != nil && v.statement.Create != nil
}
func (v *ValidatedStatement) IsDelete() bool {
return v != nil && v.statement != nil && v.statement.Delete != nil
}
func (v *ValidatedStatement) mustBeSealed() error {
if v == nil || v.seal != validatedSeal || v.statement == nil {
return &UnvalidatedWrapperError{Wrapper: "statement"}

View file

@ -6,8 +6,10 @@ import (
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/controller"
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/service"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/store/tikistore"
@ -35,6 +37,7 @@ type TestApp struct {
PluginControllers map[string]controller.PluginControllerInterface
PluginDefs []plugin.Plugin
MutationGate *service.TaskMutationGate
Schema ruki.Schema
taskController *controller.TaskController
statuslineConfig *model.StatuslineConfig
headerConfig *model.HeaderConfig
@ -61,6 +64,9 @@ func NewTestApp(t *testing.T) *TestApp {
config.ResetPathManager()
})
// 0.5. Create ruki schema (needed by plugin parser and controllers)
schema := rukiRuntime.NewSchema()
// 1. Create temp dir for task files (auto-cleanup via t.TempDir())
taskDir := t.TempDir()
@ -100,6 +106,7 @@ func NewTestApp(t *testing.T) *TestApp {
taskStore,
gate,
statuslineConfig,
schema,
)
// 6. Initialize View Layer
@ -166,6 +173,7 @@ func NewTestApp(t *testing.T) *TestApp {
RootLayout: rootLayout,
TaskStore: taskStore,
MutationGate: gate,
Schema: schema,
NavController: navController,
InputRouter: inputRouter,
TaskDir: taskDir,
@ -314,7 +322,7 @@ func (ta *TestApp) Cleanup() {
// This enables testing of plugin-related functionality.
func (ta *TestApp) LoadPlugins() error {
// Load embedded plugins
plugins, err := plugin.LoadPlugins()
plugins, err := plugin.LoadPlugins(ta.Schema)
if err != nil {
return err
}
@ -338,7 +346,7 @@ func (ta *TestApp) LoadPlugins() error {
}
pc.SetLaneLayout(columns, widths)
pluginControllers[p.GetName()] = controller.NewPluginController(
ta.TaskStore, ta.MutationGate, pc, tp, ta.NavController, ta.statuslineConfig,
ta.TaskStore, ta.MutationGate, pc, tp, ta.NavController, ta.statuslineConfig, ta.Schema,
)
} else if dp, ok := p.(*plugin.DokiPlugin); ok {
pluginControllers[p.GetName()] = controller.NewDokiController(
@ -373,6 +381,7 @@ func (ta *TestApp) LoadPlugins() error {
ta.TaskStore,
ta.MutationGate,
ta.statuslineConfig,
ta.Schema,
)
// Update global input capture to handle plugin switching keys