Merge pull request #47 from boolean-maybe/feature/ruki-plugin

feature/ruki plugin
This commit is contained in:
boolean-maybe 2026-04-09 17:17:20 -04:00 committed by GitHub
commit 0fb4baa899
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 3319 additions and 5089 deletions

View file

@ -1,4 +1,6 @@
# AI skills
# AI collaboration
## AI skills
`tiki` adds optional [agent skills](https://agentskills.io/home) to the repo upon initialization
If installed you can:
@ -7,4 +9,23 @@ If installed you can:
- create, find, modify and delete tikis using AI
- create tikis/dokis directly from Markdown files
- Refer to tikis or dokis when implementing with AI-assisted development - `implement tiki xxxxxxx`
- Keep a history of prompts/plans by saving prompts or plans with your repo
- Keep a history of prompts/plans by saving prompts or plans with your repo
## Chat with AI
If you [configured](config.md) an AI agent these features will be enabled:
- open current tiki in an AI agent such as [Claude Code](https://code.claude.com) and have it read it. You can then chat or edit it without having to find it first
## Configuration
in your `config.yaml`:
```yaml
# AI agent integration
ai:
agent: claude # AI tool for chat: "claude", "gemini", "codex", "opencode"
# Enables AI collaboration features
# Omit or leave empty to disable
```

View file

@ -62,7 +62,7 @@ Search order: user config dir (base) → `.doc/workflow.yaml` (project) → cwd
**Views (plugins)** — merged by name across files. The user config is the base; project and cwd files override individual fields:
- Non-empty fields in the override replace the base (description, key, colors, view mode)
- Non-empty arrays in the override replace the entire base array (lanes, actions, sort)
- Non-empty arrays in the override replace the entire base array (lanes, actions)
- Empty/zero fields in the override are ignored — the base value is kept
- Views that only exist in the override are appended
@ -110,6 +110,12 @@ appearance:
# Examples: "dracula", "monokai", "catppuccin-macchiato"
background: "#282a36" # Code block background color (hex or ANSI e.g. "236")
border: "#6272a4" # Code block border color (hex or ANSI e.g. "244")
# AI agent integration
ai:
agent: claude # AI tool for chat: "claude", "gemini", "codex", "opencode"
# Enables AI collaboration features
# Omit or leave empty to disable
```
### workflow.yaml
@ -149,18 +155,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 = 'in_progress' and type != 'epic'
action: status = 'in_progress'
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"
@ -169,12 +174,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"
@ -183,8 +187,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"
@ -194,19 +197,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

@ -77,17 +77,16 @@ 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"
```
that translates to - show all tikis in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single lane.
The `actions` section defines a keyboard shortcut `b` that moves the selected tiki to the board by setting its status to `ready`
You define the name, description, caption colors, hotkey, tiki filter and sorting. The `description` is displayed in the header when the view is active. Save this into a `workflow.yaml` file in the config directory
You define the name, description, caption colors, hotkey, and `ruki` expressions for filtering and actions. The `description` is displayed in the header when the view is active. Save this into a `workflow.yaml` file in the config directory
Likewise the documentation is just a plugin:
@ -119,28 +118,27 @@ name: Custom
foreground: "#5fff87"
background: "#005f00"
key: "F4"
sort: Priority, Title
lanes:
- name: Ready
columns: 1
width: 20
filter: status = 'ready'
action: status = 'ready'
filter: select where status = "ready" order by priority, title
action: update where id = id() set status="ready"
- name: In Progress
columns: 1
width: 30
filter: status = 'in_progress'
action: status = 'in_progress'
filter: select where status = "inProgress" order by priority, title
action: update where id = id() set status="inProgress"
- name: Review
columns: 1
width: 30
filter: status = 'review'
action: status = 'review'
filter: select where status = "review" order by priority, title
action: update where id = id() set status="review"
- name: Done
columns: 1
width: 20
filter: status = 'done'
action: status = 'done'
filter: select where status = "done" order by priority, title
action: update where id = id() set status="done"
```
### Lane width
@ -168,135 +166,92 @@ that apply to the currently selected tiki via a keyboard shortcut. These shortcu
actions:
- key: "b"
label: "Add to board"
action: status = 'ready'
action: update where id = id() set status="ready"
- key: "a"
label: "Assign to me"
action: assignee = CURRENT_USER
action: update where id = id() set assignee=user()
```
Each action has:
- `key` - a single printable character used as the keyboard shortcut
- `label` - description shown in the header
- `action` - an action expression (same syntax as lane actions, see below)
- `action` - a `ruki` `update` statement (same syntax as lane actions, see below)
When the shortcut key is pressed, the action is applied to the currently selected tiki.
For example, pressing `b` in the Backlog plugin changes the selected tiki's status to `ready`, effectively moving it to the board.
### Action expression
### ruki expressions
The `action: status = 'backlog'` statement in a plugin is an action to be run when a tiki is moved into the lane. Here `=`
means `assign` so status is assigned `backlog` when the tiki is moved. Likewise you can manipulate tags using `+-` (add)
or `-=` (remove) expressions. For example, `tags += [idea, UI]` adds `idea` and `UI` tags to a tiki
Plugin filters, lane actions, and plugin actions all use the [ruki](ruki/index.md) language. Filters use `select` statements and actions use `update` statements.
#### Supported Fields
#### Filter (select)
- `status` - set workflow status (must be a key defined in `workflow.yaml` statuses)
- `type` - set task type: `story`, `bug`, `spike`, `epic` (case-insensitive)
- `priority` - set numeric priority (1-5)
- `points` - set numeric points (0 or positive, up to max points)
- `assignee` - set assignee string
- `tags` - add/remove tags (list)
- `dependsOn` - add/remove dependency tiki IDs (list)
- `due` - set due date (YYYY-MM-DD format)
- `recurrence` - set recurrence pattern (cron format: `0 0 * * *`, `0 0 * * MON`, `0 0 1 * *`)
The `filter` field uses a `ruki` `select` statement to determine which tikis appear in a lane. Sorting is part of the select — use `order by` to control display order.
#### Operators
```sql
-- basic filter with sort
select where status = "backlog" and type != "epic" order by priority, id
- `=` assigns a value to `status`, `type`, `priority`, `points`, `assignee`, `due`, `recurrence`
- `+=` adds tags or dependencies, `-=` removes them
- multiple operations are separated by commas: `status=done, tags+=[moved]`
-- recent items, most recent first
select where now() - updatedAt < 24hour order by updatedAt desc
#### Literals
-- multiple conditions
select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
- strings can be quoted (`'in_progress'`, `"alex"`) or bare (`done`, `alex`)
- use quotes when the value has spaces
- integers are used for `priority` and `points`
- tag lists use brackets: `tags += [ui, frontend]`
- `CURRENT_USER` assigns the current git user to `assignee`
- example: `assignee = CURRENT_USER`
### Filter expression
The `filter: status = 'backlog'` statement in a plugin is a filter expression that determines which tikis appear in the view.
#### Supported Fields
You can filter on these task fields:
- `id` - Task identifier (e.g., 'TIKI-m7n2xk')
- `title` - Task title text (case-insensitive)
- `type` - Task type: 'story', 'bug', 'spike', or 'epic' (case-insensitive)
- `status` - Workflow status (must match a key defined in `workflow.yaml` statuses)
- `assignee` - Assigned user (case-insensitive)
- `priority` - Numeric priority value
- `points` - Story points estimate
- `tags` (or `tag`) - List of tags (case-insensitive)
- `dependsOn` - List of dependency tiki IDs
- `due` - Due date (YYYY-MM-DD format, supports time arithmetic)
- `recurrence` - Recurrence pattern (cron string, compared as string)
- `createdAt` - Creation timestamp
- `updatedAt` - Last update timestamp
All string comparisons are case-insensitive.
#### Operators
- **Comparison**: `=` (or `==`), `!=`, `>`, `>=`, `<`, `<=`
- **Logical**: `AND`, `OR`, `NOT` (precedence: NOT > AND > OR)
- **Membership**: `IN`, `NOT IN` (check if value in list using `[val1, val2]`)
- **Grouping**: Use parentheses `()` to control evaluation order
#### Literals and Special Values
**Special expressions**:
- `CURRENT_USER` - Resolves to the current git user (works in comparisons and IN lists)
- `NOW` - Current timestamp
**Time expressions**:
- `NOW - UpdatedAt` - Time elapsed since update
- `NOW - CreatedAt` - Time since creation
- Duration units: `min`/`minutes`, `hour`/`hours`, `day`/`days`, `week`/`weeks`, `month`/`months`
- Examples: `2hours`, `14days`, `3weeks`, `60min`, `1month`
- Operators: `+` (add), `-` (subtract or compute duration)
**Special tag semantics**:
- `tags IN ['ui', 'frontend']` matches if ANY task tag matches ANY list value
- This allows intersection testing across tag arrays
#### Examples
```text
# Multiple statuses
status = 'ready' OR status = 'in_progress'
# With tags
tags IN ['frontend', 'urgent']
# High priority bugs
type = 'bug' AND priority = 0
# Features and ideas assigned to me
(type = 'feature' OR tags IN ['idea']) AND assignee = CURRENT_USER
# Unassigned large tasks
assignee = '' AND points >= 5
# Recently created tasks not in backlog
(NOW - CreatedAt < 2hours) AND status != 'backlog'
-- assigned to me
select where assignee = user() order by priority
```
### Sorting
#### Action (update)
The `sort` field determines the order in which tikis appear in the view. You can sort by one or more fields, and control the direction (ascending or descending).
The `action` field uses a `ruki` `update` statement. In plugin context, `id()` refers to the currently selected tiki.
#### Sort Syntax
```sql
-- set status on move
update where id = id() set status="ready"
```text
sort: Field1, Field2 DESC, Field3
-- set multiple fields
update where id = id() set status="backlog" priority=2
-- assign to current user
update where id = id() set assignee=user()
```
#### Examples
#### Supported fields
```text
# Sort by creation time descending (recent first), then priority, then title
sort: CreatedAt DESC, Priority, Title
```
- `id` - task identifier (e.g., "TIKI-M7N2XK")
- `title` - task title text
- `type` - task type: "story", "bug", "spike", or "epic"
- `status` - workflow status (must match a key defined in `workflow.yaml` statuses)
- `assignee` - assigned user
- `priority` - numeric priority value (1-5)
- `points` - story points estimate
- `tags` - list of tags
- `dependsOn` - list of dependency tiki IDs
- `due` - due date (YYYY-MM-DD format)
- `recurrence` - recurrence pattern (cron format)
- `createdAt` - creation timestamp
- `updatedAt` - last update timestamp
#### Conditions
- **Comparison**: `=`, `!=`, `>`, `>=`, `<`, `<=`
- **Logical**: `and`, `or`, `not` (precedence: not > and > or)
- **Membership**: `"value" in field`, `status not in ["done", "cancelled"]`
- **Emptiness**: `assignee is empty`, `tags is not empty`
- **Quantifiers**: `dependsOn any status != "done"`, `dependsOn all status = "done"`
- **Grouping**: parentheses `()` to control evaluation order
#### Literals and built-ins
- Strings: double-quoted (`"ready"`, `"alex"`)
- Integers: `1`, `5`
- Dates: `2026-03-25`
- Durations: `2hour`, `14day`, `3week`, `1month`
- Lists: `["bug", "frontend"]`
- `user()` — current user
- `now()` — current timestamp
- `id()` — currently selected tiki (in plugin context)
- `count(select where ...)` — count matching tikis
For the full language reference, see the [ruki documentation](ruki/index.md).

View file

@ -5,8 +5,8 @@
- [Markdown viewer](markdown-viewer.md)
- [Image support](image-requirements.md)
- [Customization](customization.md)
- [Ruki](ruki/index.md)
- [ruki](ruki/index.md)
- [tiki format](tiki-format.md)
- [Quick capture](quick-capture.md)
- [AI skills](skills.md)
- [AI collaboration](ai.md)
- [Recipes](ideas/triggers.md)

View file

@ -1,14 +1,14 @@
# Ruki Documentation
# ruki Documentation
## Table of contents
- [Ruki](#ruki)
- [ruki](#ruki)
- [Quick start](#quick-start)
- [More details](#more-details)
## Ruki
## ruki
This section documents the Ruki language. Ruki is a small language for finding, creating, updating, and deleting tikis, with SQL-like statements and trigger rules.
This section documents the `ruki` language. `ruki` is a small language for finding, creating, updating, and deleting tikis, with SQL-like statements and trigger rules.
## Quick start

View file

@ -13,7 +13,7 @@
## Overview
This page describes the operators and built-in functions available in Ruki.
This page describes the operators and built-in functions available in `ruki`.
## Operator precedence and associativity
@ -201,7 +201,7 @@ create title="x" dependsOn=dependsOn + tags
## Built-in functions
Ruki has these built-ins:
`ruki` has these built-ins:
| Name | Result type | Arguments | Notes |
|---|---|---|---|
@ -246,7 +246,7 @@ after update where new.status = "in progress" run("echo hello")
## Shell-related forms
Ruki includes two shell-related forms:
`ruki` includes two shell-related forms:
- `call(...)` as a string-returning expression
- `run(...)` as an `after`-trigger action

View file

@ -11,18 +11,18 @@
## Overview
This page is a practical introduction to the Ruki language. It covers the main statement forms, the conditions they use, and the trigger rules that let you block or react to changes.
This page is a practical introduction to the `ruki` language. It covers the main statement forms, the conditions they use, and the trigger rules that let you block or react to changes.
## Mental model
Ruki has two top-level forms:
`ruki` has two top-level forms:
- Statements: `select`, `create`, `update`, and `delete`
- Triggers: `before` or `after` rules attached to `create`, `update`, or `delete`
Statements read and change tiki fields such as `status`, `type`, `tags`, `dependsOn`, `priority`, and `due`. Triggers use the same fields and conditions, but add `before` or `after` timing around `create`, `update`, or `delete`.
The simplest way to read Ruki is:
The simplest way to read `ruki` is:
- `select` filters tikis
- `create` assigns fields for a new tiki

View file

@ -11,7 +11,7 @@
## Overview
This page explains how Ruki statements, triggers, conditions, and expressions behave.
This page explains how `ruki` statements, triggers, conditions, and expressions behave.
## Statement semantics

View file

@ -12,11 +12,11 @@
## Overview
This page describes Ruki syntax. It starts with tokens and then shows the grammar for statements, triggers, conditions, and expressions.
This page describes `ruki` syntax. It starts with tokens and then shows the grammar for statements, triggers, conditions, and expressions.
## Lexical structure
Ruki uses these token classes:
`ruki` uses these token classes:
- comments: `--` to end of line
- whitespace: ignored between tokens

View file

@ -63,7 +63,7 @@ after delete
Triggers are defined in `workflow.yaml` under the `triggers:` key. Each entry has two fields:
- `ruki` — the trigger rule in Ruki syntax (required)
- `ruki` — the trigger rule in `ruki` syntax (required)
- `description` — an optional label
```yaml
@ -309,7 +309,7 @@ Time triggers are parsed and validated at startup alongside event triggers. A pa
Triggers are loaded during application startup, after the store is initialized but before controllers are created.
- Each trigger definition is parsed with the ruki parser. A parse error in any trigger is **fail-fast**: the application will not start, and the error message identifies the failing trigger by its `description` (or by index if no description is set).
- Each trigger definition is parsed with the `ruki` parser. A parse error in any trigger is **fail-fast**: the application will not start, and the error message identifies the failing trigger by its `description` (or by index if no description is set).
- If no `triggers:` section is found in any workflow file, zero triggers are loaded and the app starts normally.
- Successfully loaded triggers are logged with a count at startup.

View file

@ -13,11 +13,11 @@
## Overview
This page explains the value types used in Ruki. You do not write types explicitly. Ruki works them out from the values, expressions, built-in functions, and tiki fields you use.
This page explains the value types used in `ruki`. You do not write types explicitly. `ruki` works them out from the values, expressions, built-in functions, and tiki fields you use.
## Value types
Ruki uses these value types:
`ruki` uses these value types:
| Type | Meaning |
|---|---|
@ -37,7 +37,7 @@ Ruki uses these value types:
## Field catalog
The workflow field catalog exposes these fields to Ruki:
The workflow field catalog exposes these fields to `ruki`:
| Field | Type |
|---|---|

View file

@ -13,13 +13,13 @@
## Overview
This page explains the errors you can get in Ruki. It covers syntax errors, unknown fields, type mismatches, invalid enum values, unsupported operators, and invalid trigger structure.
This page explains the errors you can get in `ruki`. It covers syntax errors, unknown fields, type mismatches, invalid enum values, unsupported operators, and invalid trigger structure.
## Validation layers
![Validation pipeline](images/validation-pipeline.svg)
Ruki has two distinct failure stages:
`ruki` has two distinct failure stages:
1. Parse-time failures
2. Validation-time failures

View file

@ -194,11 +194,11 @@ When asked to remove or clear a due date:
### Query by due date
Users can filter tasks by due date in the TUI using filter expressions:
- `due = '2026-04-01'` - exact match
- `due < '2026-04-01'` - before date
- `due < NOW` - overdue tasks
- `due - NOW < 7day` - due within 7 days
Users can filter tasks by due date in the TUI using `ruki` select statements:
- `select where due = 2026-04-01` - exact match
- `select where due < 2026-04-01` - before date
- `select where due < now()` - overdue tasks
- `select where due - now() < 7day` - due within 7 days
### Validation

View file

@ -68,7 +68,7 @@ func DefaultTheme() Theme {
LabelColor: colors.BurndownChartLabelColor,
ValueColor: colors.BurndownChartValueColor,
BarColor: colors.BurndownChartBarColor,
BackgroundColor: config.GetContentBackgroundColor(),
BackgroundColor: config.GetColors().ContentBackgroundColor,
BarGradientFrom: colors.BurndownChartGradientFrom.Start,
BarGradientTo: colors.BurndownChartGradientTo.Start,
DotChar: '⣿', // braille full cell for dense dot matrix

View file

@ -44,7 +44,7 @@ func barFillColor(bar Bar, row, total int, theme Theme) tcell.Color {
// Use adaptive gradient: solid color when gradients disabled
if !config.UseGradients {
return config.FallbackBurndownColor
return config.GetColors().FallbackBurndownColor
}
t := float64(row) / float64(total-1)

View file

@ -25,10 +25,9 @@ func NewCompletionPrompt(words []string) *CompletionPrompt {
inputField := tview.NewInputField()
// Configure the input field
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
inputField.SetFieldTextColor(config.GetContentTextColor())
colors := config.GetColors()
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor)
inputField.SetFieldTextColor(colors.ContentTextColor)
cp := &CompletionPrompt{
InputField: inputField,
words: words,

View file

@ -27,8 +27,9 @@ type DateEdit struct {
// NewDateEdit creates a new date input field.
func NewDateEdit() *DateEdit {
inputField := tview.NewInputField()
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
inputField.SetFieldTextColor(config.GetContentTextColor())
colors := config.GetColors()
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor)
inputField.SetFieldTextColor(colors.ContentTextColor)
de := &DateEdit{
InputField: inputField,

View file

@ -29,8 +29,9 @@ func NewEditSelectList(values []string, allowTyping bool) *EditSelectList {
inputField := tview.NewInputField()
// Configure the input field
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
inputField.SetFieldTextColor(config.GetContentTextColor())
colors := config.GetColors()
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor)
inputField.SetFieldTextColor(colors.ContentTextColor)
esl := &EditSelectList{
InputField: inputField,

View file

@ -37,8 +37,9 @@ func NewIntEditSelect(min, max int, allowTyping bool) *IntEditSelect {
}
inputField := tview.NewInputField()
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
inputField.SetFieldTextColor(config.GetContentTextColor())
colors := config.GetColors()
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor)
inputField.SetFieldTextColor(colors.ContentTextColor)
ies := &IntEditSelect{
InputField: inputField,

View file

@ -31,8 +31,9 @@ type RecurrenceEdit struct {
// NewRecurrenceEdit creates a new recurrence editor.
func NewRecurrenceEdit() *RecurrenceEdit {
inputField := tview.NewInputField()
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
inputField.SetFieldTextColor(config.GetContentTextColor())
colors := config.GetColors()
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor)
inputField.SetFieldTextColor(colors.ContentTextColor)
re := &RecurrenceEdit{
InputField: inputField,

View file

@ -37,7 +37,7 @@ func NewTaskList(maxVisibleRows int) *TaskList {
Box: tview.NewBox(),
maxVisibleRows: maxVisibleRows,
idGradient: colors.TaskBoxIDColor,
idFallback: config.FallbackTaskIDColor,
idFallback: colors.FallbackTaskIDColor,
titleColor: colors.TaskBoxTitleColor,
selectionColor: colors.TaskListSelectionColor,
statusDoneColor: colors.TaskListStatusDoneColor,

View file

@ -40,7 +40,7 @@ func TestNewTaskList(t *testing.T) {
if tl.idGradient != colors.TaskBoxIDColor {
t.Error("Expected ID gradient from config")
}
if tl.idFallback != config.FallbackTaskIDColor {
if tl.idFallback != config.GetColors().FallbackTaskIDColor {
t.Error("Expected ID fallback from config")
}
if tl.titleColor != colors.TaskBoxTitleColor {

View file

@ -59,7 +59,7 @@ func (w *WordList) Draw(screen tcell.Screen) {
}
wordStyle := tcell.StyleDefault.Foreground(w.fgColor).Background(w.bgColor)
spaceStyle := tcell.StyleDefault.Background(config.GetContentBackgroundColor())
spaceStyle := tcell.StyleDefault.Background(config.GetColors().ContentBackgroundColor)
currentX := x
currentY := y

View file

@ -45,6 +45,11 @@ type ColorConfig struct {
TaskDetailEditFocusText string // tview color string like "[white]"
TaskDetailTagForeground tcell.Color
TaskDetailTagBackground tcell.Color
TaskDetailPlaceholderColor tcell.Color
// Content area colors (base canvas for editable/readable content)
ContentBackgroundColor tcell.Color
ContentTextColor tcell.Color
// Search box colors
SearchBoxLabelColor tcell.Color
@ -87,6 +92,13 @@ type ColorConfig struct {
HeaderActionViewKeyColor string // tview color string for view action keys
HeaderActionViewLabelColor string // tview color string for view action labels
// Plugin-specific colors
DepsEditorBackground tcell.Color // muted slate for dependency editor caption
// Fallback solid colors for gradient scenarios (used when UseGradients = false)
FallbackTaskIDColor tcell.Color // Deep Sky Blue (end of task ID gradient)
FallbackBurndownColor tcell.Color // Purple (start of burndown gradient)
// Statusline colors (bottom bar, powerline style)
StatuslineBg string // hex color for stat segment background, e.g. "#3a3a5c"
StatuslineFg string // hex color for stat segment text, e.g. "#cccccc"
@ -142,6 +154,11 @@ func DefaultColors() *ColorConfig {
TaskDetailEditFocusText: "[white]", // White text after arrow
TaskDetailTagForeground: tcell.NewRGBColor(180, 200, 220), // Light blue-gray text
TaskDetailTagBackground: tcell.NewRGBColor(30, 50, 120), // Dark blue background (more bluish)
TaskDetailPlaceholderColor: tcell.ColorGray, // Gray for placeholder text in edit fields
// Content area (base canvas)
ContentBackgroundColor: tcell.ColorBlack, // dark theme: explicit black
ContentTextColor: tcell.ColorWhite, // dark theme: white text
// Search box
SearchBoxLabelColor: tcell.ColorWhite,
@ -196,6 +213,13 @@ func DefaultColors() *ColorConfig {
HeaderActionViewKeyColor: "#5fafff", // cyan for view-specific actions
HeaderActionViewLabelColor: "#808080", // gray for view-specific labels
// Plugin-specific
DepsEditorBackground: tcell.NewHexColor(0x4e5768), // Muted slate
// Fallback solid colors (no-gradient terminals)
FallbackTaskIDColor: tcell.NewRGBColor(0, 191, 255), // Deep Sky Blue
FallbackBurndownColor: tcell.NewRGBColor(134, 90, 214), // Purple
// Statusline (Nord theme)
StatuslineBg: "#434c5e", // Nord polar night 3
StatuslineFg: "#d8dee9", // Nord snow storm 1
@ -221,30 +245,14 @@ var UseGradients bool
// Screen-wide gradients show more banding on 256-color terminals, so require truecolor
var UseWideGradients bool
// Plugin-specific background colors for code-only plugins
var (
// DepsEditorBackground: muted slate for the dependency editor caption
DepsEditorBackground = tcell.NewHexColor(0x4e5768)
)
// Fallback solid colors for gradient scenarios (used when UseGradients = false)
var (
// Caption title fallback: Royal Blue (end of gradient)
FallbackTitleColor = tcell.NewRGBColor(65, 105, 225)
// Task ID fallback: Deep Sky Blue (end of gradient)
FallbackTaskIDColor = tcell.NewRGBColor(0, 191, 255)
// Burndown chart fallback: Purple (start of gradient)
FallbackBurndownColor = tcell.NewRGBColor(134, 90, 214)
// Caption row fallback: Midpoint of Midnight Blue to Royal Blue
FallbackCaptionColor = tcell.NewRGBColor(45, 65, 169)
)
// GetColors returns the global color configuration with theme-aware overrides
func GetColors() *ColorConfig {
if !colorsInitialized {
globalColors = DefaultColors()
// Apply theme-aware overrides for critical text colors
if GetEffectiveTheme() == "light" {
globalColors.ContentBackgroundColor = tcell.ColorDefault
globalColors.ContentTextColor = tcell.ColorBlack
globalColors.SearchBoxLabelColor = tcell.ColorBlack
globalColors.SearchBoxTextColor = tcell.ColorBlack
globalColors.InputFieldTextColor = tcell.ColorBlack

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

@ -10,7 +10,6 @@ import (
"path/filepath"
"strings"
"github.com/gdamore/tcell/v2"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
@ -375,24 +374,6 @@ func GetEffectiveTheme() string {
return "dark" // default fallback
}
// GetContentBackgroundColor returns the background color for markdown content areas
// Dark theme needs black background for light text; light theme uses terminal default
func GetContentBackgroundColor() tcell.Color {
if GetEffectiveTheme() == "dark" {
return tcell.ColorBlack
}
return tcell.ColorDefault
}
// GetContentTextColor returns the appropriate text color for content areas
// Dark theme uses white text; light theme uses black text
func GetContentTextColor() tcell.Color {
if GetEffectiveTheme() == "dark" {
return tcell.ColorWhite
}
return tcell.ColorBlack
}
// GetGradientThreshold returns the minimum color count required for gradients
// Valid values: 16, 256, 16777216 (truecolor)
func GetGradientThreshold() int {

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) {
@ -723,3 +724,65 @@ func TestDepsController_SuccessfulSwitchPersistsClampedTargetScroll(t *testing.T
t.Fatalf("expected clamped scroll offset 1, got %d", got)
}
}
func TestDepsController_ShowNavigation(t *testing.T) {
dc, _ := newDepsTestEnv(t)
if dc.ShowNavigation() {
t.Error("DepsController.ShowNavigation() should return false")
}
}
func TestDepsController_GetFilteredTasksForLane_WithSearch(t *testing.T) {
dc, taskStore := newDepsTestEnv(t)
// set search results to only include the free task
free := taskStore.GetTask(testFreeID)
dc.pluginConfig.SetSearchResults([]task.SearchResult{{Task: free, Score: 1.0}}, "Free")
// All lane should now only show the free task (matching search)
allTasks := dc.GetFilteredTasksForLane(depsLaneAll)
if len(allTasks) != 1 {
t.Fatalf("expected 1 task with search narrowing, got %d", len(allTasks))
}
if allTasks[0].ID != testFreeID {
t.Errorf("expected %s, got %s", testFreeID, allTasks[0].ID)
}
// Blocks lane should be empty (no matching search results)
blocksTasks := dc.GetFilteredTasksForLane(depsLaneBlocks)
if len(blocksTasks) != 0 {
t.Errorf("expected 0 blocks tasks with search narrowing, got %d", len(blocksTasks))
}
}
func TestDepsController_GetFilteredTasksForLane_MissingContextTask(t *testing.T) {
dc, taskStore := newDepsTestEnv(t)
// delete the context task
taskStore.DeleteTask(testCtxID)
// all lanes should return nil when context task is missing
if dc.GetFilteredTasksForLane(depsLaneAll) != nil {
t.Error("expected nil when context task is missing")
}
if dc.GetFilteredTasksForLane(depsLaneBlocks) != nil {
t.Error("expected nil when context task is missing")
}
if dc.GetFilteredTasksForLane(depsLaneDepends) != nil {
t.Error("expected nil when context task is missing")
}
}
func TestDepsController_MoveTask_EmptySelection(t *testing.T) {
dc, _ := newDepsTestEnv(t)
// set selected lane to blocks but with an index beyond the task list
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
dc.pluginConfig.SetSelectedIndexForLane(depsLaneBlocks, 99) // beyond available tasks
// getSelectedTaskID should return "" for an index beyond the task list,
// so handleMoveTask should return false
if dc.handleMoveTask(1) {
t.Error("expected false when no task is selected (index out of range)")
}
}

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,
}
}
@ -228,7 +232,7 @@ func (ir *InputRouter) openDepsEditor(taskID string) bool {
Description: model.DepsEditorViewDesc,
ConfigIndex: -1,
Type: "tiki",
Background: config.DepsEditorBackground,
Background: config.GetColors().DepsEditorBackground,
},
TaskID: taskID,
Lanes: []plugin.TikiLane{
@ -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,20 +784,462 @@ 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")
}
// task should still have original status
tk := taskStore.GetTask("T-1")
if tk.Status != task.StatusReady {
t.Errorf("expected status ready, got %s", tk.Status)
tk2 := taskStore.GetTask("T-1")
if tk2.Status != task.StatusReady {
t.Errorf("expected status ready, got %s", tk2.Status)
}
}
func TestPluginController_HandlePluginAction_Create(t *testing.T) {
taskStore := store.NewInMemoryStore()
readyFilter := mustParseStmt(t, `select where status = "ready"`)
createAction := mustParseStmt(t, `create title="New Task" status="ready" type="story" priority=3`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
Actions: []plugin.PluginAction{
{
Rune: 'c',
Label: "Create",
Action: createAction,
},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
pluginConfig.SetSelectedLane(0)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
if !pc.HandleAction(pluginActionID('c')) {
t.Error("expected true for successful create action")
}
allTasks := taskStore.GetAllTasks()
if len(allTasks) != 1 {
t.Fatalf("expected 1 task after create, got %d", len(allTasks))
}
if allTasks[0].Title != "New Task" {
t.Errorf("expected title 'New Task', got %q", allTasks[0].Title)
}
}
func TestPluginController_HandlePluginAction_Delete(t *testing.T) {
taskStore := store.NewInMemoryStore()
_ = taskStore.CreateTask(&task.Task{
ID: "T-1", Title: "Task 1", Status: task.StatusDone, Type: task.TypeStory, Priority: 3,
})
doneFilter := mustParseStmt(t, `select where status = "done"`)
deleteAction := mustParseStmt(t, `delete where status = "done"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Done", Columns: 1, Filter: doneFilter}},
Actions: []plugin.PluginAction{
{
Rune: 'x',
Label: "Delete Done",
Action: deleteAction,
},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
pluginConfig.SetSelectedLane(0)
pluginConfig.SetSelectedIndexForLane(0, 0)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
if !pc.HandleAction(pluginActionID('x')) {
t.Error("expected true for successful delete action")
}
if taskStore.GetTask("T-1") != nil {
t.Error("task should have been deleted")
}
}
func TestPluginController_HandlePluginAction_NoMatchingRune(t *testing.T) {
taskStore := store.NewInMemoryStore()
_ = taskStore.CreateTask(&task.Task{
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
})
readyFilter := mustParseStmt(t, `select where status = "ready"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
Actions: []plugin.PluginAction{},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
if pc.HandleAction(pluginActionID('z')) {
t.Error("expected false for non-matching plugin action rune")
}
}
func TestPluginController_HandlePluginAction_NoSelectedTask(t *testing.T) {
taskStore := store.NewInMemoryStore()
emptyFilter := mustParseStmt(t, `select where status = "done"`)
updateAction := mustParseStmt(t, `update where id = id() set status = "done"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Empty", Columns: 1, Filter: emptyFilter}},
Actions: []plugin.PluginAction{
{Rune: 'd', Label: "Done", Action: updateAction},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
if pc.HandleAction(pluginActionID('d')) {
t.Error("expected false when no task is selected for update action")
}
}
func TestPluginController_HandleMoveTask_NoActionOnTargetLane(t *testing.T) {
taskStore := store.NewInMemoryStore()
_ = taskStore.CreateTask(&task.Task{
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
})
readyFilter := mustParseStmt(t, `select where status = "ready"`)
doneFilter := mustParseStmt(t, `select where status = "done"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{
{Name: "Ready", Columns: 1, Filter: readyFilter},
{Name: "Done", Columns: 1, Filter: doneFilter},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1, 1}, nil)
pluginConfig.SetSelectedLane(0)
pluginConfig.SetSelectedIndexForLane(0, 0)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
if pc.HandleAction(ActionMoveTaskRight) {
t.Error("expected false when target lane has no action")
}
}
func TestPluginController_HandleMoveTask_Success(t *testing.T) {
taskStore := store.NewInMemoryStore()
_ = taskStore.CreateTask(&task.Task{
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
})
readyFilter := mustParseStmt(t, `select where status = "ready"`)
doneFilter := mustParseStmt(t, `select where status = "done"`)
doneAction := 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},
{Name: "Done", Columns: 1, Filter: doneFilter, Action: doneAction},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1, 1}, nil)
pluginConfig.SetSelectedLane(0)
pluginConfig.SetSelectedIndexForLane(0, 0)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
if !pc.HandleAction(ActionMoveTaskRight) {
t.Error("expected true for successful move")
}
tk := taskStore.GetTask("T-1")
if tk.Status != "done" {
t.Errorf("expected status done, got %s", tk.Status)
}
}
func TestPluginController_HandleMoveTask_OutOfBounds(t *testing.T) {
taskStore := store.NewInMemoryStore()
_ = taskStore.CreateTask(&task.Task{
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
})
readyFilter := mustParseStmt(t, `select where status = "ready"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
pluginConfig.SetSelectedLane(0)
pluginConfig.SetSelectedIndexForLane(0, 0)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
if pc.HandleAction(ActionMoveTaskLeft) {
t.Error("expected false for out-of-bounds move left")
}
if pc.HandleAction(ActionMoveTaskRight) {
t.Error("expected false for out-of-bounds move right")
}
}
func TestPluginController_GetFilteredTasksForLane_NilPluginDef(t *testing.T) {
taskStore := store.NewInMemoryStore()
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := &PluginController{
pluginBase: pluginBase{
taskStore: taskStore,
mutationGate: gate,
pluginConfig: pluginConfig,
pluginDef: nil,
schema: schema,
},
}
if tasks := pc.GetFilteredTasksForLane(0); tasks != nil {
t.Error("expected nil for nil pluginDef")
}
}
func TestPluginController_GetFilteredTasksForLane_OutOfRange(t *testing.T) {
taskStore := store.NewInMemoryStore()
readyFilter := mustParseStmt(t, `select where status = "ready"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
if tasks := pc.GetFilteredTasksForLane(-1); tasks != nil {
t.Error("expected nil for negative lane")
}
if tasks := pc.GetFilteredTasksForLane(5); tasks != nil {
t.Error("expected nil for out-of-range lane")
}
}
func TestPluginController_GetFilteredTasksForLane_NilFilter(t *testing.T) {
taskStore := store.NewInMemoryStore()
_ = taskStore.CreateTask(&task.Task{
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
})
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "All", Columns: 1}},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
tasks := pc.GetFilteredTasksForLane(0)
if len(tasks) != 1 {
t.Errorf("expected all tasks when filter is nil, got %d", len(tasks))
}
}
func TestPluginController_GetFilteredTasksForLane_WithSearchNarrowing(t *testing.T) {
taskStore := store.NewInMemoryStore()
_ = taskStore.CreateTask(&task.Task{
ID: "T-1", Title: "Alpha", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
})
_ = taskStore.CreateTask(&task.Task{
ID: "T-2", Title: "Beta", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
})
readyFilter := mustParseStmt(t, `select where status = "ready"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
t1 := taskStore.GetTask("T-1")
pluginConfig.SetSearchResults([]task.SearchResult{{Task: t1, Score: 1.0}}, "Alpha")
tasks := pc.GetFilteredTasksForLane(0)
if len(tasks) != 1 {
t.Fatalf("expected 1 task with search narrowing, got %d", len(tasks))
}
if tasks[0].ID != "T-1" {
t.Errorf("expected T-1, got %s", tasks[0].ID)
}
}
func TestPluginController_HandleAction_UnknownAction(t *testing.T) {
taskStore := store.NewInMemoryStore()
readyFilter := mustParseStmt(t, `select where status = "ready"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
if pc.HandleAction("nonexistent_action") {
t.Error("expected false for unknown action")
}
}
func TestPluginController_HandleSearch(t *testing.T) {
taskStore := store.NewInMemoryStore()
_ = taskStore.CreateTask(&task.Task{
ID: "T-1", Title: "Alpha", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
})
readyFilter := mustParseStmt(t, `select where status = "ready"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
pc.HandleSearch("Alpha")
results := pluginConfig.GetSearchResults()
if results == nil {
t.Fatal("expected search results")
}
}
func TestPluginController_ShowNavigation(t *testing.T) {
taskStore := store.NewInMemoryStore()
readyFilter := mustParseStmt(t, `select where status = "ready"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
if !pc.ShowNavigation() {
t.Error("PluginController.ShowNavigation() should return true")
}
}
func TestPluginController_HandleToggleViewMode(t *testing.T) {
taskStore := store.NewInMemoryStore()
readyFilter := mustParseStmt(t, `select where status = "ready"`)
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1}, nil)
schema := rukiRuntime.NewSchema()
gate := service.NewTaskMutationGate()
gate.SetStore(taskStore)
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
before := pluginConfig.GetViewMode()
if !pc.HandleAction(ActionToggleViewMode) {
t.Error("expected true for toggle view mode")
}
after := pluginConfig.GetViewMode()
if before == after {
t.Error("view mode should change after toggle")
}
}
func TestGetPluginActionRune(t *testing.T) {
tests := []struct {
name string
id ActionID
want rune
}{
{"valid", pluginActionID('d'), 'd'},
{"not a plugin action", "some_action", 0},
{"empty suffix", ActionID(pluginActionPrefix), 0},
{"multi-char suffix", ActionID(pluginActionPrefix + "ab"), 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := getPluginActionRune(tt.id); got != tt.want {
t.Errorf("getPluginActionRune(%q) = %q, want %q", tt.id, got, tt.want)
}
})
}
}

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)
}
})
}

585
plugin/legacy_convert.go Normal file
View file

@ -0,0 +1,585 @@
package plugin
import (
"fmt"
"log/slog"
"regexp"
"strings"
"sync"
"github.com/boolean-maybe/tiki/workflow"
)
// fieldNameMap maps lowercase field names to their canonical form.
// Built once from workflow.Fields() + the "tag"→"tags" alias.
var (
fieldNameMap map[string]string
fieldNameOnce sync.Once
)
func buildFieldNameMap() {
fieldNameOnce.Do(func() {
fields := workflow.Fields()
fieldNameMap = make(map[string]string, len(fields)+1)
for _, f := range fields {
fieldNameMap[strings.ToLower(f.Name)] = f.Name
}
fieldNameMap["tag"] = "tags" // singular alias
})
}
// normalizeFieldName returns the canonical field name for a case-insensitive input.
// Returns the input unchanged if not found in the catalog.
func normalizeFieldName(name string) string {
buildFieldNameMap()
if canonical, ok := fieldNameMap[strings.ToLower(name)]; ok {
return canonical
}
return name
}
// isArrayField returns true if the normalized field name is a list type (tags, dependsOn).
func isArrayField(name string) bool {
canonical := normalizeFieldName(name)
f, ok := workflow.Field(canonical)
if !ok {
return false
}
return f.Type == workflow.TypeListString || f.Type == workflow.TypeListRef
}
// LegacyConfigTransformer converts old-format workflow expressions to ruki.
type LegacyConfigTransformer struct{}
// NewLegacyConfigTransformer creates a new transformer.
func NewLegacyConfigTransformer() *LegacyConfigTransformer {
return &LegacyConfigTransformer{}
}
// ConvertPluginConfig converts all legacy expressions in a plugin config in-place.
// Returns the number of fields converted.
func (t *LegacyConfigTransformer) ConvertPluginConfig(cfg *pluginFileConfig) int {
count := 0
// convert sort once (shared across lanes)
var convertedSort string
if cfg.Sort != "" {
convertedSort = t.ConvertSort(cfg.Sort)
slog.Warn("workflow.yaml uses deprecated 'sort' field — consider using 'order by' in lane filters",
"plugin", cfg.Name)
}
// convert lane filters and actions
for i := range cfg.Lanes {
lane := &cfg.Lanes[i]
if lane.Filter != "" && !isRukiFilter(lane.Filter) {
newFilter := t.ConvertFilter(lane.Filter)
slog.Debug("converted legacy filter", "old", lane.Filter, "new", newFilter, "lane", lane.Name)
lane.Filter = newFilter
count++
}
// merge sort into lane filter
if convertedSort != "" {
lane.Filter = mergeSortIntoFilter(lane.Filter, convertedSort)
}
if lane.Action != "" && !isRukiAction(lane.Action) {
newAction, err := t.ConvertAction(lane.Action)
if err != nil {
slog.Warn("failed to convert legacy action, passing through",
"error", err, "action", lane.Action, "lane", lane.Name)
continue
}
slog.Debug("converted legacy action", "old", lane.Action, "new", newAction, "lane", lane.Name)
lane.Action = newAction
count++
}
}
// convert plugin-level shortcut actions
for i := range cfg.Actions {
action := &cfg.Actions[i]
if action.Action != "" && !isRukiAction(action.Action) {
newAction, err := t.ConvertAction(action.Action)
if err != nil {
slog.Warn("failed to convert legacy plugin action, passing through",
"error", err, "action", action.Action, "key", action.Key)
continue
}
slog.Debug("converted legacy plugin action", "old", action.Action, "new", newAction, "key", action.Key)
action.Action = newAction
count++
}
}
// clear sort after merging
if cfg.Sort != "" {
count++
cfg.Sort = ""
}
return count
}
// isRukiFilter returns true if the expression is already in ruki format.
func isRukiFilter(expr string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(expr)), "select")
}
// isRukiAction returns true if the expression is already in ruki format.
func isRukiAction(expr string) bool {
lower := strings.ToLower(strings.TrimSpace(expr))
return strings.HasPrefix(lower, "update") ||
strings.HasPrefix(lower, "create") ||
strings.HasPrefix(lower, "delete")
}
// mergeSortIntoFilter appends an order-by clause to a filter, respecting existing order-by.
func mergeSortIntoFilter(filter, orderBy string) string {
if strings.Contains(strings.ToLower(filter), "order by") {
return filter
}
if filter == "" {
return "select " + orderBy
}
return filter + " " + orderBy
}
// --- Filter conversion ---
//
// Transformation order (documented for maintenance):
// 1. Single quotes → double quotes
// 2. == → = (before any =-based pattern matching)
// 3. tag singular alias rewrites (before generic keyword lowering)
// 4. tags/array field IN/NOT IN expansion (before generic IN lowering)
// 5. NOT IN → not in (before standalone NOT)
// 6. Remaining keyword lowering: AND, OR, NOT, IN
// 7. NOW → now(), CURRENT_USER → user()
// 8. Field name normalization
// 9. Duration plural stripping
// pre-compiled regexes for filter conversion
var (
// step 1: single quotes → double quotes
reSingleQuoted = regexp.MustCompile(`'([^']*)'`)
// step 2: == → =
reDoubleEquals = regexp.MustCompile(`==`)
// step 3: tag singular alias — equality: tag = "value" → "value" in tags
reTagEquality = regexp.MustCompile(`(?i)\btag\b\s*=\s*("(?:[^"]*)")`)
// step 3: tag singular alias — IN: tag IN [...] (captured together with tags IN below)
// step 4: field IN [...] and field NOT IN [...]
reFieldNotIn = regexp.MustCompile(`(?i)(\w+)\s+NOT\s+IN\s*\[([^\]]*)\]`)
reFieldIn = regexp.MustCompile(`(?i)(\w+)\s+IN\s*\[([^\]]*)\]`)
// step 5: NOT IN → not in (word-bounded to avoid partial matches)
reNotIn = regexp.MustCompile(`(?i)\bNOT\s+IN\b`)
// step 6: keyword lowering
reAnd = regexp.MustCompile(`(?i)\bAND\b`)
reOr = regexp.MustCompile(`(?i)\bOR\b`)
reNot = regexp.MustCompile(`(?i)\bNOT\b`)
reIn = regexp.MustCompile(`(?i)\bIN\b`)
// step 7: NOW → now(), CURRENT_USER → user()
reNow = regexp.MustCompile(`(?i)\bNOW\b`)
reCurrentUser = regexp.MustCompile(`(?i)\bCURRENT_USER\b`)
// step 8: field name normalization — matches identifiers before operators
reFieldBeforeOp = regexp.MustCompile(`\b([a-zA-Z]\w*)\b`)
// step 9: duration plural stripping — e.g. 24hours → 24hour, 1weeks → 1week
reDurationUnit = regexp.MustCompile(`(\d+)(hour|minute|day|week|month|year)s\b`)
)
// ConvertFilter converts an old-format filter expression to a ruki select statement.
func (t *LegacyConfigTransformer) ConvertFilter(filter string) string {
if filter == "" {
return ""
}
s := strings.TrimSpace(filter)
// step 1: single quotes → double quotes
s = reSingleQuoted.ReplaceAllString(s, `"$1"`)
// step 2: == → =
s = reDoubleEquals.ReplaceAllString(s, "=")
// step 3: tag singular alias — equality
// tag = "value" → "value" in tags
s = reTagEquality.ReplaceAllStringFunc(s, func(match string) string {
m := reTagEquality.FindStringSubmatch(match)
return m[1] + " in tags"
})
// step 3+4: field NOT IN [...] (must come before field IN)
s = reFieldNotIn.ReplaceAllStringFunc(s, func(match string) string {
m := reFieldNotIn.FindStringSubmatch(match)
fieldName := m[1]
values := m[2]
return expandNotInClause(fieldName, values)
})
// step 4: field IN [...]
s = reFieldIn.ReplaceAllStringFunc(s, func(match string) string {
m := reFieldIn.FindStringSubmatch(match)
fieldName := m[1]
values := m[2]
return expandInClause(fieldName, values)
})
// step 5: NOT IN → not in
s = reNotIn.ReplaceAllString(s, "not in")
// step 6: keyword lowering
s = reAnd.ReplaceAllString(s, "and")
s = reOr.ReplaceAllString(s, "or")
s = reNot.ReplaceAllString(s, "not")
s = reIn.ReplaceAllString(s, "in")
// step 7: NOW → now(), CURRENT_USER → user()
s = reNow.ReplaceAllString(s, "now()")
s = reCurrentUser.ReplaceAllString(s, "user()")
// step 8: field name normalization
s = normalizeFieldNames(s)
// step 9: duration plural stripping
s = reDurationUnit.ReplaceAllString(s, "${1}${2}")
return "select where " + s
}
// expandInClause handles field IN [...] expansion.
// For array fields (tags, dependsOn): expands to ("v1" in field or "v2" in field).
// For scalar fields: lowercases to field in [...].
func expandInClause(fieldName, valuesStr string) string {
if isArrayField(fieldName) {
canonical := normalizeFieldName(fieldName)
values := parseBracketValues(valuesStr)
if len(values) == 1 {
return values[0] + " in " + canonical
}
parts := make([]string, len(values))
for i, v := range values {
parts[i] = v + " in " + canonical
}
return "(" + strings.Join(parts, " or ") + ")"
}
// scalar field: just lowercase the IN
canonical := normalizeFieldName(fieldName)
return canonical + " in [" + normalizeQuotedValues(valuesStr) + "]"
}
// expandNotInClause handles field NOT IN [...] expansion.
// For array fields: expands to ("v1" not in field and "v2" not in field).
// For scalar fields: lowercases to field not in [...].
func expandNotInClause(fieldName, valuesStr string) string {
if isArrayField(fieldName) {
canonical := normalizeFieldName(fieldName)
values := parseBracketValues(valuesStr)
if len(values) == 1 {
return values[0] + " not in " + canonical
}
parts := make([]string, len(values))
for i, v := range values {
parts[i] = v + " not in " + canonical
}
return "(" + strings.Join(parts, " and ") + ")"
}
canonical := normalizeFieldName(fieldName)
return canonical + " not in [" + normalizeQuotedValues(valuesStr) + "]"
}
// parseBracketValues parses a comma-separated list of values from inside [...].
// Ensures all values are double-quoted.
func parseBracketValues(s string) []string {
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
// strip existing quotes and re-quote with double quotes
p = strings.Trim(p, `"'`)
result = append(result, `"`+p+`"`)
}
return result
}
// normalizeQuotedValues ensures all values in a comma-separated list use double quotes.
func normalizeQuotedValues(s string) string {
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
p = strings.Trim(p, `"'`)
result = append(result, `"`+p+`"`)
}
return strings.Join(result, ", ")
}
// normalizeFieldNames replaces known field names with their canonical forms.
// Only replaces identifiers outside of double-quoted strings.
func normalizeFieldNames(s string) string {
buildFieldNameMap()
var b strings.Builder
b.Grow(len(s))
i := 0
for i < len(s) {
// skip quoted strings verbatim
if s[i] == '"' {
j := i + 1
for j < len(s) && s[j] != '"' {
j++
}
if j < len(s) {
j++ // include closing quote
}
b.WriteString(s[i:j])
i = j
continue
}
// try to match an identifier at current position
loc := reFieldBeforeOp.FindStringIndex(s[i:])
if loc == nil {
b.WriteString(s[i:])
break
}
// check if there's a quote in the gap before this match — if so,
// only advance to that quote and let the next iteration skip it
gap := s[i : i+loc[0]]
if qIdx := strings.IndexByte(gap, '"'); qIdx >= 0 {
b.WriteString(s[i : i+qIdx])
i += qIdx
continue
}
// write non-identifier text before the match
b.WriteString(gap)
match := s[i+loc[0] : i+loc[1]]
lower := strings.ToLower(match)
if canonical, ok := fieldNameMap[lower]; ok {
b.WriteString(canonical)
} else {
b.WriteString(match)
}
i += loc[1]
}
return b.String()
}
// --- Sort conversion ---
// ConvertSort converts an old-format sort expression to a ruki order-by clause.
func (t *LegacyConfigTransformer) ConvertSort(sort string) string {
if sort == "" {
return ""
}
parts := strings.Split(sort, ",")
converted := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
tokens := strings.Fields(p)
if len(tokens) == 0 {
continue
}
fieldName := normalizeFieldName(tokens[0])
if len(tokens) > 1 && strings.EqualFold(tokens[1], "DESC") {
converted = append(converted, fieldName+" desc")
} else {
converted = append(converted, fieldName)
}
}
return "order by " + strings.Join(converted, ", ")
}
// --- Action conversion ---
// ConvertAction converts an old-format action expression to a ruki update statement.
func (t *LegacyConfigTransformer) ConvertAction(action string) (string, error) {
if action == "" {
return "", nil
}
segments, err := splitTopLevelCommas(strings.TrimSpace(action))
if err != nil {
return "", fmt.Errorf("splitting action segments: %w", err)
}
setParts := make([]string, 0, len(segments))
for _, seg := range segments {
seg = strings.TrimSpace(seg)
if seg == "" {
continue
}
part, err := convertActionSegment(seg)
if err != nil {
return "", fmt.Errorf("converting segment %q: %w", seg, err)
}
setParts = append(setParts, part)
}
return "update where id = id() set " + strings.Join(setParts, " "), nil
}
// convertActionSegment converts a single assignment like "status='ready'" or "tags+=[frontend]".
func convertActionSegment(seg string) (string, error) {
// detect += and -= operators first
if idx := strings.Index(seg, "+="); idx > 0 {
fieldName := normalizeFieldName(strings.TrimSpace(seg[:idx]))
value := strings.TrimSpace(seg[idx+2:])
converted := convertBracketValues(value)
return fieldName + "=" + fieldName + "+" + converted, nil
}
if idx := strings.Index(seg, "-="); idx > 0 {
fieldName := normalizeFieldName(strings.TrimSpace(seg[:idx]))
value := strings.TrimSpace(seg[idx+2:])
converted := convertBracketValues(value)
return fieldName + "=" + fieldName + "-" + converted, nil
}
// simple = assignment
idx := strings.Index(seg, "=")
if idx < 0 {
return "", fmt.Errorf("no assignment operator found in %q", seg)
}
// handle == (old format uses both = and ==)
fieldName := normalizeFieldName(strings.TrimSpace(seg[:idx]))
value := strings.TrimSpace(seg[idx+1:])
if strings.HasPrefix(value, "=") {
value = strings.TrimSpace(value[1:]) // skip second =
}
// convert single quotes to double quotes
value = reSingleQuoted.ReplaceAllString(value, `"$1"`)
// convert CURRENT_USER
value = reCurrentUser.ReplaceAllString(value, "user()")
// quote bare identifiers
value = quoteIfBareIdentifier(value)
return fieldName + "=" + value, nil
}
// convertBracketValues converts a bracket-enclosed list, quoting bare identifiers.
// e.g. [frontend, 'needs review'] → ["frontend", "needs review"]
func convertBracketValues(s string) string {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") {
// not bracket-enclosed, just quote it
s = reSingleQuoted.ReplaceAllString(s, `"$1"`)
return quoteIfBareIdentifier(s)
}
inner := s[1 : len(s)-1]
parts := strings.Split(inner, ",")
converted := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
// strip existing quotes
p = strings.Trim(p, `"'`)
// re-quote — all values in action brackets must be strings
converted = append(converted, `"`+p+`"`)
}
return "[" + strings.Join(converted, ", ") + "]"
}
var (
// matches function calls like now(), user(), id()
reFunctionCall = regexp.MustCompile(`^\w+\(\)$`)
// matches numeric values (int or float)
reNumeric = regexp.MustCompile(`^-?\d+(\.\d+)?$`)
// matches bare identifiers (not already quoted, not numeric, not a function)
reBareIdentifier = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)
)
// quoteIfBareIdentifier wraps a value in double quotes if it's a bare identifier
// (not numeric, not a function call, not already quoted).
func quoteIfBareIdentifier(value string) string {
if value == "" {
return value
}
if strings.HasPrefix(value, `"`) {
return value // already quoted
}
if reNumeric.MatchString(value) {
return value // numeric
}
if reFunctionCall.MatchString(value) {
return value // function call
}
if reBareIdentifier.MatchString(value) {
return `"` + value + `"`
}
return value
}
// splitTopLevelCommas splits a string on commas, respecting [...] brackets and quotes.
func splitTopLevelCommas(input string) ([]string, error) {
var result []string
var current strings.Builder
depth := 0
inSingle := false
inDouble := false
for i := 0; i < len(input); i++ {
ch := input[i]
switch {
case ch == '\'' && !inDouble:
inSingle = !inSingle
current.WriteByte(ch)
case ch == '"' && !inSingle:
inDouble = !inDouble
current.WriteByte(ch)
case ch == '[' && !inSingle && !inDouble:
depth++
current.WriteByte(ch)
case ch == ']' && !inSingle && !inDouble:
depth--
if depth < 0 {
return nil, fmt.Errorf("unmatched ']' at position %d", i)
}
current.WriteByte(ch)
case ch == ',' && depth == 0 && !inSingle && !inDouble:
result = append(result, current.String())
current.Reset()
default:
current.WriteByte(ch)
}
}
if depth != 0 {
return nil, fmt.Errorf("unmatched '[' in expression")
}
if inSingle || inDouble {
return nil, fmt.Errorf("unclosed quote in expression")
}
if current.Len() > 0 {
result = append(result, current.String())
}
return result, nil
}

View file

@ -0,0 +1,943 @@
package plugin
import (
"strings"
"testing"
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/task"
"gopkg.in/yaml.v3"
)
func TestConvertLegacyFilter(t *testing.T) {
tr := NewLegacyConfigTransformer()
tests := []struct {
name string
old string
want string
}{
{
name: "simple comparison",
old: "status = 'ready'",
want: `select where status = "ready"`,
},
{
name: "multi-condition and",
old: "status = 'ready' AND type != 'epic'",
want: `select where status = "ready" and type != "epic"`,
},
{
name: "time expression with NOW",
old: "NOW - UpdatedAt < 24hours",
want: `select where now() - updatedAt < 24hour`,
},
{
name: "duration plural weeks",
old: "NOW - CreatedAt < 1weeks",
want: `select where now() - createdAt < 1week`,
},
{
name: "tags IN array expansion",
old: "tags IN ['ui', 'charts']",
want: `select where ("ui" in tags or "charts" in tags)`,
},
{
name: "tags IN single element",
old: "tags IN ['ui']",
want: `select where "ui" in tags`,
},
{
name: "tags NOT IN array expansion",
old: "tags NOT IN ['ui', 'old']",
want: `select where ("ui" not in tags and "old" not in tags)`,
},
{
name: "status IN scalar",
old: "status IN ['ready', 'inProgress']",
want: `select where status in ["ready", "inProgress"]`,
},
{
name: "status NOT IN scalar",
old: "status NOT IN ['done']",
want: `select where status not in ["done"]`,
},
{
name: "NOT with parens",
old: "NOT (status = 'done')",
want: `select where not (status = "done")`,
},
{
name: "tag singular alias equality",
old: "tag = 'ui'",
want: `select where "ui" in tags`,
},
{
name: "tag singular alias IN",
old: "tag IN ['ui', 'charts']",
want: `select where ("ui" in tags or "charts" in tags)`,
},
{
name: "tag singular alias NOT IN",
old: "tag NOT IN ['ui', 'old']",
want: `select where ("ui" not in tags and "old" not in tags)`,
},
{
name: "CURRENT_USER",
old: "assignee = CURRENT_USER",
want: `select where assignee = user()`,
},
{
name: "double equals",
old: "priority == 3",
want: `select where priority = 3`,
},
{
name: "mixed case keywords",
old: "type = 'epic' And status = 'ready'",
want: `select where type = "epic" and status = "ready"`,
},
{
name: "numeric comparison",
old: "priority > 2",
want: `select where priority > 2`,
},
{
name: "empty string",
old: "",
want: "",
},
{
name: "passthrough already ruki",
old: "select where status = \"ready\"",
want: "select where status = \"ready\"",
},
{
name: "whitespace variations",
old: " status = 'ready' ",
want: `select where status = "ready"`,
},
{
name: "field name normalization CreatedAt",
old: "CreatedAt > 0",
want: `select where createdAt > 0`,
},
{
name: "field name normalization Priority",
old: "Priority > 2",
want: `select where priority > 2`,
},
{
name: "dependsOn field normalization",
old: "DependsOn IN ['TIKI-ABC123']",
want: `select where "TIKI-ABC123" in dependsOn`,
},
{
name: "case variations Or",
old: "status = 'ready' Or status = 'done'",
want: `select where status = "ready" or status = "done"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// passthrough check
if tt.old != "" && isRukiFilter(tt.old) {
if tt.old != tt.want {
t.Errorf("passthrough mismatch: got %q, want %q", tt.old, tt.want)
}
return
}
got := tr.ConvertFilter(tt.old)
if got != tt.want {
t.Errorf("ConvertFilter(%q)\n got: %q\n want: %q", tt.old, got, tt.want)
}
})
}
}
func TestConvertLegacySort(t *testing.T) {
tr := NewLegacyConfigTransformer()
tests := []struct {
name string
old string
want string
}{
{
name: "single field",
old: "Priority",
want: "order by priority",
},
{
name: "multi field",
old: "Priority, CreatedAt",
want: "order by priority, createdAt",
},
{
name: "DESC",
old: "UpdatedAt DESC",
want: "order by updatedAt desc",
},
{
name: "mixed",
old: "Priority, Points DESC",
want: "order by priority, points desc",
},
{
name: "empty",
old: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tr.ConvertSort(tt.old)
if got != tt.want {
t.Errorf("ConvertSort(%q)\n got: %q\n want: %q", tt.old, got, tt.want)
}
})
}
}
func TestConvertLegacySortMerging(t *testing.T) {
tests := []struct {
name string
sort string
lanes []PluginLaneConfig
wantFilter []string // expected filter for each lane after merge
}{
{
name: "sort + non-empty lane filter",
sort: "Priority",
lanes: []PluginLaneConfig{
{Name: "ready", Filter: "status = 'ready'"},
},
wantFilter: []string{`select where status = "ready" order by priority`},
},
{
name: "sort + empty lane filter",
sort: "Priority",
lanes: []PluginLaneConfig{
{Name: "all", Filter: ""},
},
wantFilter: []string{"select order by priority"},
},
{
name: "sort + lane already has order by",
sort: "Priority",
lanes: []PluginLaneConfig{
{Name: "custom", Filter: "select where status = \"ready\" order by updatedAt desc"},
},
wantFilter: []string{`select where status = "ready" order by updatedAt desc`},
},
{
name: "no sort field",
sort: "",
lanes: []PluginLaneConfig{
{Name: "ready", Filter: "status = 'ready'"},
},
wantFilter: []string{`select where status = "ready"`},
},
}
tr := NewLegacyConfigTransformer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &pluginFileConfig{
Name: "test",
Sort: tt.sort,
Lanes: tt.lanes,
}
tr.ConvertPluginConfig(cfg)
if len(cfg.Lanes) != len(tt.wantFilter) {
t.Fatalf("lane count mismatch: got %d, want %d", len(cfg.Lanes), len(tt.wantFilter))
}
for i, want := range tt.wantFilter {
if cfg.Lanes[i].Filter != want {
t.Errorf("lane %d filter:\n got: %q\n want: %q", i, cfg.Lanes[i].Filter, want)
}
}
if tt.sort != "" && cfg.Sort != "" {
t.Error("Sort field was not cleared after merging")
}
})
}
}
func TestConvertLegacyAction(t *testing.T) {
tr := NewLegacyConfigTransformer()
tests := []struct {
name string
old string
want string
wantErr bool
}{
{
name: "simple status",
old: "status = 'ready'",
want: `update where id = id() set status="ready"`,
},
{
name: "multiple assignments",
old: "status = 'backlog', priority = 1",
want: `update where id = id() set status="backlog" priority=1`,
},
{
name: "tags add",
old: "tags+=[frontend, 'needs review']",
want: `update where id = id() set tags=tags+["frontend", "needs review"]`,
},
{
name: "tags remove",
old: "tags-=[old]",
want: `update where id = id() set tags=tags-["old"]`,
},
{
name: "dependsOn add",
old: "dependsOn+=[TIKI-ABC123]",
want: `update where id = id() set dependsOn=dependsOn+["TIKI-ABC123"]`,
},
{
name: "CURRENT_USER",
old: "assignee=CURRENT_USER",
want: `update where id = id() set assignee=user()`,
},
{
name: "unquoted string value",
old: "status=done",
want: `update where id = id() set status="done"`,
},
{
name: "bare identifiers in brackets",
old: "tags+=[frontend, backend]",
want: `update where id = id() set tags=tags+["frontend", "backend"]`,
},
{
name: "integer value",
old: "priority=2",
want: `update where id = id() set priority=2`,
},
{
name: "multiple mixed",
old: "status=done, type=bug, priority=2, points=3, assignee='Alice', tags+=[frontend], dependsOn+=[TIKI-ABC123]",
want: `update where id = id() set status="done" type="bug" priority=2 points=3 assignee="Alice" tags=tags+["frontend"] dependsOn=dependsOn+["TIKI-ABC123"]`,
},
{
name: "empty",
old: "",
want: "",
},
{
name: "passthrough already ruki",
old: `update where id = id() set status="ready"`,
want: `update where id = id() set status="ready"`,
},
{
name: "malformed brackets",
old: "tags+=[frontend",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.old != "" && isRukiAction(tt.old) {
// passthrough — already ruki
if tt.old != tt.want {
t.Errorf("passthrough mismatch: got %q, want %q", tt.old, tt.want)
}
return
}
got, err := tr.ConvertAction(tt.old)
if tt.wantErr {
if err == nil {
t.Errorf("ConvertAction(%q) expected error, got %q", tt.old, got)
}
return
}
if err != nil {
t.Fatalf("ConvertAction(%q) unexpected error: %v", tt.old, err)
}
if got != tt.want {
t.Errorf("ConvertAction(%q)\n got: %q\n want: %q", tt.old, got, tt.want)
}
})
}
}
func TestConvertLegacyPluginActions(t *testing.T) {
tr := NewLegacyConfigTransformer()
cfg := &pluginFileConfig{
Name: "test",
Lanes: []PluginLaneConfig{
{Name: "all", Filter: ""},
},
Actions: []PluginActionConfig{
{Key: "b", Label: "Ready", Action: "status = 'ready'"},
{Key: "c", Label: "Done", Action: `update where id = id() set status="done"`},
},
}
tr.ConvertPluginConfig(cfg)
if cfg.Actions[0].Action != `update where id = id() set status="ready"` {
t.Errorf("action 0 not converted: %q", cfg.Actions[0].Action)
}
if cfg.Actions[1].Action != `update where id = id() set status="done"` {
t.Errorf("action 1 was modified: %q", cfg.Actions[1].Action)
}
}
func TestConvertLegacyMixedFormats(t *testing.T) {
tr := NewLegacyConfigTransformer()
cfg := &pluginFileConfig{
Name: "test",
Lanes: []PluginLaneConfig{
{Name: "old", Filter: "status = 'ready'", Action: "status = 'inProgress'"},
{Name: "new", Filter: `select where status = "done"`, Action: `update where id = id() set status="done"`},
},
}
tr.ConvertPluginConfig(cfg)
// old lane should be converted
if cfg.Lanes[0].Filter != `select where status = "ready"` {
t.Errorf("old lane filter not converted: %q", cfg.Lanes[0].Filter)
}
if cfg.Lanes[0].Action != `update where id = id() set status="inProgress"` {
t.Errorf("old lane action not converted: %q", cfg.Lanes[0].Action)
}
// new lane should be unchanged
if cfg.Lanes[1].Filter != `select where status = "done"` {
t.Errorf("new lane filter was modified: %q", cfg.Lanes[1].Filter)
}
if cfg.Lanes[1].Action != `update where id = id() set status="done"` {
t.Errorf("new lane action was modified: %q", cfg.Lanes[1].Action)
}
}
func TestConvertLegacyActionPassthroughPrefixes(t *testing.T) {
// create and delete prefixes must not be re-converted
tests := []struct {
name string
action string
}{
{name: "create prefix", action: `create where type = "bug" set status="ready"`},
{name: "delete prefix", action: `delete where id = id()`},
{name: "update prefix", action: `update where id = id() set status="done"`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !isRukiAction(tt.action) {
t.Errorf("isRukiAction(%q) returned false, expected true", tt.action)
}
})
}
}
func TestConvertLegacyEdgeCases(t *testing.T) {
tr := NewLegacyConfigTransformer()
t.Run("tag vs tags word boundary", func(t *testing.T) {
// "tags IN" should not trigger the singular "tag" alias path
got := tr.ConvertFilter("tags IN ['ui', 'charts']")
want := `select where ("ui" in tags or "charts" in tags)`
if got != want {
t.Errorf("got: %q\nwant: %q", got, want)
}
})
t.Run("tag NOT IN treated as tags NOT IN", func(t *testing.T) {
got := tr.ConvertFilter("tag NOT IN ['ui', 'old']")
want := `select where ("ui" not in tags and "old" not in tags)`
if got != want {
t.Errorf("got: %q\nwant: %q", got, want)
}
})
t.Run("array NOT IN single element no parens", func(t *testing.T) {
got := tr.ConvertFilter("tags NOT IN ['old']")
want := `select where "old" not in tags`
if got != want {
t.Errorf("got: %q\nwant: %q", got, want)
}
})
t.Run("array IN single element no parens", func(t *testing.T) {
got := tr.ConvertFilter("tags IN ['ui']")
want := `select where "ui" in tags`
if got != want {
t.Errorf("got: %q\nwant: %q", got, want)
}
})
t.Run("values with spaces in quotes", func(t *testing.T) {
got := tr.ConvertFilter("status = 'in progress'")
want := `select where status = "in progress"`
if got != want {
t.Errorf("got: %q\nwant: %q", got, want)
}
})
t.Run("already double-quoted values", func(t *testing.T) {
got := tr.ConvertFilter(`status = "ready"`)
want := `select where status = "ready"`
if got != want {
t.Errorf("got: %q\nwant: %q", got, want)
}
})
t.Run("dependsOn single element", func(t *testing.T) {
got := tr.ConvertFilter("DependsOn IN ['TIKI-ABC123']")
want := `select where "TIKI-ABC123" in dependsOn`
if got != want {
t.Errorf("got: %q\nwant: %q", got, want)
}
})
t.Run("field name inside quotes not normalized", func(t *testing.T) {
// "Type" as a string value must not be lowercased to "type"
got := tr.ConvertFilter("title = 'Type'")
want := `select where title = "Type"`
if got != want {
t.Errorf("got: %q\nwant: %q", got, want)
}
})
t.Run("field name inside double quotes not normalized", func(t *testing.T) {
got := tr.ConvertFilter(`assignee = "Status"`)
want := `select where assignee = "Status"`
if got != want {
t.Errorf("got: %q\nwant: %q", got, want)
}
})
t.Run("field name in bracket values not normalized", func(t *testing.T) {
got := tr.ConvertFilter("status IN ['Priority', 'Type']")
want := `select where status in ["Priority", "Type"]`
if got != want {
t.Errorf("got: %q\nwant: %q", got, want)
}
})
}
func TestConvertLegacyFullConfig(t *testing.T) {
tr := NewLegacyConfigTransformer()
cfg := &pluginFileConfig{
Name: "board",
Sort: "Priority, CreatedAt",
Lanes: []PluginLaneConfig{
{Name: "Backlog", Filter: "status = 'backlog'", Action: "status = 'backlog'"},
{Name: "Ready", Filter: "status = 'ready'", Action: "status = 'ready'"},
{Name: "In Progress", Filter: "status = 'inProgress'", Action: "status = 'inProgress'"},
{Name: "Done", Filter: "status = 'done'", Action: "status = 'done'"},
},
Actions: []PluginActionConfig{
{Key: "b", Label: "Backlog", Action: "status = 'backlog'"},
},
}
count := tr.ConvertPluginConfig(cfg)
// 4 filters + 4 actions + 1 plugin action + 1 sort = 10
if count != 10 {
t.Errorf("expected 10 conversions, got %d", count)
}
// verify sort was cleared
if cfg.Sort != "" {
t.Error("Sort field was not cleared")
}
// verify all converted expressions parse through ruki
schema := testSchema()
parser := ruki.NewParser(schema)
for _, lane := range cfg.Lanes {
if lane.Filter != "" {
_, err := parser.ParseAndValidateStatement(lane.Filter, ruki.ExecutorRuntimePlugin)
if err != nil {
t.Errorf("lane %q filter failed ruki parse: %v\n filter: %s", lane.Name, err, lane.Filter)
}
}
if lane.Action != "" {
_, err := parser.ParseAndValidateStatement(lane.Action, ruki.ExecutorRuntimePlugin)
if err != nil {
t.Errorf("lane %q action failed ruki parse: %v\n action: %s", lane.Name, err, lane.Action)
}
}
}
for _, action := range cfg.Actions {
if action.Action != "" {
_, err := parser.ParseAndValidateStatement(action.Action, ruki.ExecutorRuntimePlugin)
if err != nil {
t.Errorf("plugin action %q failed ruki parse: %v\n action: %s", action.Key, err, action.Action)
}
}
}
}
// TestLegacyWorkflowEndToEnd tests the full pipeline from legacy YAML string
// through conversion and parsing to plugin creation and execution.
func TestLegacyWorkflowEndToEnd(t *testing.T) {
legacyYAML := `views:
- name: Board
default: true
key: "F1"
sort: Priority, CreatedAt
lanes:
- name: Backlog
filter: status = 'backlog' AND tags NOT IN ['blocked']
action: status = 'backlog'
- name: Ready
filter: status = 'ready' AND assignee = CURRENT_USER
action: status = 'ready', tags+=[reviewed]
- name: Done
filter: status = 'done'
action: status = 'done'
actions:
- key: b
label: Bug
action: type = 'bug', priority = 1
`
var wf WorkflowFile
if err := yaml.Unmarshal([]byte(legacyYAML), &wf); err != nil {
t.Fatalf("failed to unmarshal legacy YAML: %v", err)
}
// convert legacy expressions
transformer := NewLegacyConfigTransformer()
for i := range wf.Plugins {
transformer.ConvertPluginConfig(&wf.Plugins[i])
}
// parse into plugin — this validates ruki parsing succeeds
schema := testSchema()
p, err := parsePluginConfig(wf.Plugins[0], "test", schema)
if err != nil {
t.Fatalf("parsePluginConfig failed: %v", err)
}
tp, ok := p.(*TikiPlugin)
if !ok {
t.Fatalf("expected TikiPlugin, got %T", p)
}
if tp.Name != "Board" {
t.Errorf("expected name Board, got %s", tp.Name)
}
if !tp.Default {
t.Error("expected default=true")
}
if len(tp.Lanes) != 3 {
t.Fatalf("expected 3 lanes, got %d", len(tp.Lanes))
}
if len(tp.Actions) != 1 {
t.Fatalf("expected 1 action, got %d", len(tp.Actions))
}
// verify all lanes have parsed filter and action statements
for i, lane := range tp.Lanes {
if lane.Filter == nil {
t.Errorf("lane %d (%s): expected non-nil filter", i, lane.Name)
}
if !lane.Filter.IsSelect() {
t.Errorf("lane %d (%s): expected select statement", i, lane.Name)
}
if lane.Action == nil {
t.Errorf("lane %d (%s): expected non-nil action", i, lane.Name)
}
if !lane.Action.IsUpdate() {
t.Errorf("lane %d (%s): expected update statement", i, lane.Name)
}
}
// execute filters against test tasks to verify they actually work
executor := newTestExecutor()
allTasks := []*task.Task{
{ID: "TIKI-000001", Status: task.StatusBacklog, Priority: 3, Tags: []string{}, Assignee: "testuser"},
{ID: "TIKI-000002", Status: task.StatusBacklog, Priority: 1, Tags: []string{"blocked"}, Assignee: "testuser"},
{ID: "TIKI-000003", Status: task.StatusReady, Priority: 2, Assignee: "testuser"},
{ID: "TIKI-000004", Status: task.StatusReady, Priority: 1, Assignee: "other"},
{ID: "TIKI-000005", Status: task.StatusDone, Priority: 5, Assignee: "testuser"},
}
// backlog lane: status='backlog' AND tags NOT IN ['blocked']
result, err := executor.Execute(tp.Lanes[0].Filter, allTasks)
if err != nil {
t.Fatalf("backlog filter execute: %v", err)
}
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "TIKI-000001" {
t.Errorf("backlog lane: expected [TIKI-000001], got %v", taskIDs(result.Select.Tasks))
}
// ready lane: status='ready' AND assignee = CURRENT_USER
result, err = executor.Execute(tp.Lanes[1].Filter, allTasks)
if err != nil {
t.Fatalf("ready filter execute: %v", err)
}
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "TIKI-000003" {
t.Errorf("ready lane: expected [TIKI-000003], got %v", taskIDs(result.Select.Tasks))
}
// done lane: status='done'
result, err = executor.Execute(tp.Lanes[2].Filter, allTasks)
if err != nil {
t.Fatalf("done filter execute: %v", err)
}
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "TIKI-000005" {
t.Errorf("done lane: expected [TIKI-000005], got %v", taskIDs(result.Select.Tasks))
}
// verify sort was merged: backlog filter should have order by
backlogFilter := wf.Plugins[0].Lanes[0].Filter
if !strings.Contains(backlogFilter, "order by") {
t.Errorf("expected sort merged into backlog filter, got: %s", backlogFilter)
}
// execute ready lane action to verify tag append works
actionResult, err := executor.Execute(tp.Lanes[1].Action, []*task.Task{
{ID: "TIKI-000003", Status: task.StatusReady, Tags: []string{}},
}, ruki.ExecutionInput{SelectedTaskID: "TIKI-000003"})
if err != nil {
t.Fatalf("ready action execute: %v", err)
}
updated := actionResult.Update.Updated
if len(updated) != 1 {
t.Fatalf("expected 1 updated task, got %d", len(updated))
}
if updated[0].Status != task.StatusReady {
t.Errorf("expected status ready, got %v", updated[0].Status)
}
if !containsString(updated[0].Tags, "reviewed") {
t.Errorf("expected 'reviewed' tag after action, got %v", updated[0].Tags)
}
}
func taskIDs(tasks []*task.Task) []string {
ids := make([]string, len(tasks))
for i, t := range tasks {
ids[i] = t.ID
}
return ids
}
func containsString(slice []string, target string) bool {
for _, s := range slice {
if s == target {
return true
}
}
return false
}
func TestConvertLegacyConversionCount(t *testing.T) {
tr := NewLegacyConfigTransformer()
// config with no legacy expressions
cfg := &pluginFileConfig{
Name: "test",
Lanes: []PluginLaneConfig{
{Name: "all", Filter: `select where status = "ready"`},
},
}
count := tr.ConvertPluginConfig(cfg)
if count != 0 {
t.Errorf("expected 0 conversions for already-ruki config, got %d", count)
}
}
func TestSplitTopLevelCommas(t *testing.T) {
tests := []struct {
name string
input string
want []string
wantErr bool
}{
{
name: "simple",
input: "a, b, c",
want: []string{"a", " b", " c"},
},
{
name: "with brackets",
input: "tags+=[a, b], status=done",
want: []string{"tags+=[a, b]", " status=done"},
},
{
name: "with quotes",
input: "status='a, b', type=bug",
want: []string{"status='a, b'", " type=bug"},
},
{
name: "unmatched bracket",
input: "tags+=[a, b",
wantErr: true,
},
{
name: "extra close bracket",
input: "a]",
wantErr: true,
},
{
name: "unclosed quote",
input: "status='open",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := splitTopLevelCommas(tt.input)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != len(tt.want) {
t.Fatalf("length mismatch: got %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("segment %d: got %q, want %q", i, got[i], tt.want[i])
}
}
})
}
}
func TestQuoteIfBareIdentifier(t *testing.T) {
tests := []struct {
input string
want string
}{
{"done", `"done"`},
{"ready", `"ready"`},
{"42", "42"},
{"3.14", "3.14"},
{"-7", "-7"},
{"-3.5", "-3.5"},
{"now()", "now()"},
{"user()", "user()"},
{`"already"`, `"already"`},
{"", ""},
{"TIKI-ABC123", `"TIKI-ABC123"`},
// not a bare identifier (contains space) — returned unchanged
{"hello world", "hello world"},
// starts with digit — not bare identifier, not numeric
{"0xDEAD", "0xDEAD"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := quoteIfBareIdentifier(tt.input)
if got != tt.want {
t.Errorf("quoteIfBareIdentifier(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestSplitTopLevelCommas_UnclosedDoubleQuote(t *testing.T) {
_, err := splitTopLevelCommas(`status="open, tags+=[a]`)
if err == nil {
t.Fatal("expected error for unclosed double quote")
}
if !strings.Contains(err.Error(), "unclosed quote") {
t.Errorf("expected 'unclosed quote' error, got: %v", err)
}
}
func TestConvertBracketValues_NonBracketEnclosed(t *testing.T) {
// bare identifier without brackets should be quoted
got := convertBracketValues("frontend")
if got != `"frontend"` {
t.Errorf("expected bare identifier to be quoted, got %q", got)
}
// single-quoted value without brackets
got = convertBracketValues("'needs review'")
if got != `"needs review"` {
t.Errorf("expected single-quoted value to be converted, got %q", got)
}
}
func TestConvertActionSegment_DoubleEquals(t *testing.T) {
got, err := convertActionSegment("status=='done'")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// == should be handled: first = is found, then value starts with = and is stripped
if got != `status="done"` {
t.Errorf("got %q, want %q", got, `status="done"`)
}
}
func TestConvertAction_NoAssignmentOperator(t *testing.T) {
tr := NewLegacyConfigTransformer()
_, err := tr.ConvertAction("garbage_without_equals")
if err == nil {
t.Fatal("expected error for no assignment operator")
}
if !strings.Contains(err.Error(), "no assignment operator") {
t.Errorf("expected 'no assignment operator' error, got: %v", err)
}
}
func TestConvertPluginConfig_ActionConvertError(t *testing.T) {
tr := NewLegacyConfigTransformer()
cfg := &pluginFileConfig{
Name: "test",
Lanes: []PluginLaneConfig{
{Name: "all", Filter: ""},
// lane action with malformed brackets should be skipped with a warning
{Name: "bad", Filter: "", Action: "tags+=[unclosed"},
},
}
count := tr.ConvertPluginConfig(cfg)
// the malformed action should be skipped (not counted), but lane filter is empty (not counted)
if count != 0 {
t.Errorf("expected 0 conversions for malformed action, got %d", count)
}
// the action should remain unchanged due to conversion failure
if cfg.Lanes[1].Action != "tags+=[unclosed" {
t.Errorf("malformed action should be passed through unchanged, got %q", cfg.Lanes[1].Action)
}
}
func TestConvertPluginConfig_PluginActionConvertError(t *testing.T) {
tr := NewLegacyConfigTransformer()
cfg := &pluginFileConfig{
Name: "test",
Lanes: []PluginLaneConfig{
{Name: "all", Filter: ""},
},
Actions: []PluginActionConfig{
{Key: "b", Label: "Bad", Action: "tags+=[unclosed"},
},
}
count := tr.ConvertPluginConfig(cfg)
if count != 0 {
t.Errorf("expected 0 conversions for malformed plugin action, got %d", count)
}
if cfg.Actions[0].Action != "tags+=[unclosed" {
t.Errorf("malformed plugin action should be passed through unchanged, got %q", cfg.Actions[0].Action)
}
}

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)
@ -34,6 +35,16 @@ func loadPluginsFromFile(path string) ([]Plugin, []string) {
return nil, nil
}
// convert legacy expressions to ruki before parsing
transformer := NewLegacyConfigTransformer()
totalConverted := 0
for i := range wf.Plugins {
totalConverted += transformer.ConvertPluginConfig(&wf.Plugins[i])
}
if totalConverted > 0 {
slog.Info("converted legacy workflow expressions to ruki", "count", totalConverted, "path", path)
}
var plugins []Plugin
var errs []string
for i, cfg := range wf.Plugins {
@ -45,7 +56,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 +118,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 +128,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))
}
@ -249,6 +236,186 @@ func TestDefaultPlugin_NoDefault(t *testing.T) {
}
}
func TestLoadPluginsFromFile_LegacyConversion(t *testing.T) {
tmpDir := t.TempDir()
// workflow with legacy filter expressions that need conversion
workflowContent := `views:
- name: Board
key: "F5"
sort: Priority
lanes:
- name: Ready
filter: status = 'ready'
action: status = 'inProgress'
`
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
t.Fatalf("write workflow.yaml: %v", err)
}
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
if len(errs) != 0 {
t.Fatalf("expected no errors, got: %v", errs)
}
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
tp, ok := plugins[0].(*TikiPlugin)
if !ok {
t.Fatalf("expected TikiPlugin, got %T", plugins[0])
}
// filter should have been converted and parsed (with order by from sort)
if tp.Lanes[0].Filter == nil {
t.Fatal("expected filter to be parsed after legacy conversion")
}
if !tp.Lanes[0].Filter.IsSelect() {
t.Error("expected SELECT filter after conversion")
}
if tp.Lanes[0].Action == nil {
t.Fatal("expected action to be parsed after legacy conversion")
}
if !tp.Lanes[0].Action.IsUpdate() {
t.Error("expected UPDATE action after conversion")
}
}
func TestLoadPluginsFromFile_UnnamedPlugin(t *testing.T) {
tmpDir := t.TempDir()
workflowContent := `views:
- name: Valid
key: "V"
lanes:
- name: Todo
filter: select where status = "ready"
- lanes:
- name: Bad
filter: select where status = "done"
`
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
t.Fatalf("write workflow.yaml: %v", err)
}
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
// unnamed plugin should be skipped, valid one should load
if len(plugins) != 1 {
t.Fatalf("expected 1 valid plugin, got %d", len(plugins))
}
if len(errs) != 1 {
t.Fatalf("expected 1 error for unnamed plugin, got %d: %v", len(errs), errs)
}
if plugins[0].GetName() != "Valid" {
t.Errorf("expected plugin 'Valid', got %q", plugins[0].GetName())
}
}
func TestLoadPluginsFromFile_InvalidYAML(t *testing.T) {
tmpDir := t.TempDir()
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
if err := os.WriteFile(workflowPath, []byte("invalid: yaml: content: ["), 0644); err != nil {
t.Fatalf("write workflow.yaml: %v", err)
}
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
if plugins != nil {
t.Error("expected nil plugins for invalid YAML")
}
if len(errs) != 1 {
t.Fatalf("expected 1 error for invalid YAML, got %d: %v", len(errs), errs)
}
}
func TestLoadPluginsFromFile_EmptyViews(t *testing.T) {
tmpDir := t.TempDir()
workflowContent := `views: []
`
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
t.Fatalf("write workflow.yaml: %v", err)
}
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
if len(plugins) != 0 {
t.Errorf("expected 0 plugins for empty views, got %d", len(plugins))
}
if len(errs) != 0 {
t.Errorf("expected 0 errors for empty views, got %d", len(errs))
}
}
func TestLoadPluginsFromFile_DokiConfigIndex(t *testing.T) {
tmpDir := t.TempDir()
workflowContent := `views:
- name: Board
key: "B"
lanes:
- name: Todo
filter: select where status = "ready"
- name: Docs
key: "D"
type: doki
fetcher: internal
text: "hello"
`
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
t.Fatalf("write workflow.yaml: %v", err)
}
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
if len(errs) != 0 {
t.Fatalf("expected no errors, got: %v", errs)
}
if len(plugins) != 2 {
t.Fatalf("expected 2 plugins, got %d", len(plugins))
}
// verify DokiPlugin has correct ConfigIndex
dp, ok := plugins[1].(*DokiPlugin)
if !ok {
t.Fatalf("expected DokiPlugin, got %T", plugins[1])
}
if dp.ConfigIndex != 1 {
t.Errorf("expected DokiPlugin ConfigIndex 1, got %d", dp.ConfigIndex)
}
}
func TestMergePluginLists(t *testing.T) {
base := []Plugin{
&TikiPlugin{BasePlugin: BasePlugin{Name: "Board", FilePath: "base.yaml"}},
&TikiPlugin{BasePlugin: BasePlugin{Name: "Bugs", FilePath: "base.yaml"}},
}
overrides := []Plugin{
&TikiPlugin{BasePlugin: BasePlugin{Name: "Board", FilePath: "override.yaml"}},
&TikiPlugin{BasePlugin: BasePlugin{Name: "NewView", FilePath: "override.yaml"}},
}
result := mergePluginLists(base, overrides)
// Bugs (non-overridden) + Board (merged) + NewView (new)
if len(result) != 3 {
t.Fatalf("expected 3 plugins, got %d", len(result))
}
names := make([]string, len(result))
for i, p := range result {
names[i] = p.GetName()
}
// Bugs should come first (non-overridden base), then Board (merged), then NewView (new)
if names[0] != "Bugs" {
t.Errorf("expected first plugin 'Bugs', got %q", names[0])
}
if names[1] != "Board" {
t.Errorf("expected second plugin 'Board', got %q", names[1])
}
if names[2] != "NewView" {
t.Errorf("expected third plugin 'NewView', got %q", names[2])
}
}
func TestDefaultPlugin_MultipleDefaults(t *testing.T) {
plugins := []Plugin{
&TikiPlugin{BasePlugin: BasePlugin{Name: "A"}},

View file

@ -10,9 +10,8 @@ type pluginFileConfig struct {
Description string `yaml:"description"` // short description shown in header info
Foreground string `yaml:"foreground"` // hex color like "#ff0000" or named color
Background string `yaml:"background"`
Key string `yaml:"key"` // single character
Filter string `yaml:"filter"`
Sort string `yaml:"sort"`
Key string `yaml:"key"` // single character
Sort string `yaml:"sort"` // deprecated: only for deserializing old configs; converted to order-by and cleared by LegacyConfigTransformer
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)
}
@ -51,12 +50,6 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
switch pluginType {
case "doki":
// Strict validation for Doki
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'")
}
@ -95,9 +88,6 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
if cfg.URL != "" {
return nil, fmt.Errorf("tiki plugin cannot have 'url'")
}
if cfg.Filter != "" {
return nil, fmt.Errorf("tiki plugin cannot have 'filter'")
}
if len(cfg.Lanes) == 0 {
return nil, fmt.Errorf("tiki plugin requires 'lanes'")
}
@ -105,6 +95,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 +112,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 +153,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 +162,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 +172,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 +206,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 +225,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
@ -46,17 +59,6 @@ func TestDokiValidation(t *testing.T) {
},
wantError: "doki plugin with internal fetcher requires 'text'",
},
{
name: "Doki with Tiki fields",
cfg: pluginFileConfig{
Name: "Doki with Filter",
Type: "doki",
Fetcher: "internal",
Text: "ok",
Filter: "status='ready'",
},
wantError: "doki plugin cannot have 'filter'",
},
{
name: "Valid File Fetcher",
cfg: pluginFileConfig{
@ -81,7 +83,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 +100,8 @@ func TestDokiValidation(t *testing.T) {
}
func TestTikiValidation(t *testing.T) {
schema := testSchema()
tests := []struct {
name string
cfg pluginFileConfig
@ -108,18 +112,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 +135,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 +158,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 +173,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 +195,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")
}
@ -198,47 +206,9 @@ func TestParsePluginConfig_UnknownType(t *testing.T) {
}
}
func TestParsePluginConfig_TikiWithInvalidFilter(t *testing.T) {
cfg := pluginFileConfig{
Name: "Test",
Key: "T",
Type: "tiki",
Filter: "invalid ( filter",
}
_, err := parsePluginConfig(cfg, "test.yaml")
if err == nil {
t.Fatal("Expected error for invalid top-level filter")
}
if !strings.Contains(err.Error(), "tiki plugin cannot have 'filter'") {
t.Errorf("Expected 'cannot have filter' error, got: %v", err)
}
}
// 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 +219,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 +232,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 +250,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 +284,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 +306,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 +337,8 @@ func TestParsePluginActions_Empty(t *testing.T) {
}
func TestParsePluginActions_Errors(t *testing.T) {
parser := testParser()
tests := []struct {
name string
configs []PluginActionConfig
@ -368,17 +346,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 +366,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 +382,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 +392,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 +409,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 +446,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")
}
@ -482,6 +459,254 @@ func TestParsePluginConfig_DokiWithActions(t *testing.T) {
}
}
func TestParsePluginConfig_LaneFilterMustBeSelect(t *testing.T) {
schema := testSchema()
cfg := pluginFileConfig{
Name: "Test",
Key: "T",
Lanes: []PluginLaneConfig{
{Name: "Bad", Filter: `update where id = id() set status = "ready"`},
},
}
_, err := parsePluginConfig(cfg, "test.yaml", schema)
if err == nil {
t.Fatal("expected error for non-SELECT filter")
}
if !strings.Contains(err.Error(), "filter must be a SELECT") {
t.Errorf("expected 'filter must be a SELECT' error, got: %v", err)
}
}
func TestParsePluginConfig_LaneActionMustBeUpdate(t *testing.T) {
schema := testSchema()
cfg := pluginFileConfig{
Name: "Test",
Key: "T",
Lanes: []PluginLaneConfig{
{Name: "Bad", Filter: `select where status = "ready"`, Action: `select where status = "done"`},
},
}
_, err := parsePluginConfig(cfg, "test.yaml", schema)
if err == nil {
t.Fatal("expected error for non-UPDATE action")
}
if !strings.Contains(err.Error(), "action must be an UPDATE") {
t.Errorf("expected 'action must be an UPDATE' error, got: %v", err)
}
}
func TestParsePluginActions_SelectRejectedAsAction(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "s", Label: "Search", Action: `select where status = "ready"`},
}
_, err := parsePluginActions(configs, parser)
if err == nil {
t.Fatal("expected error for SELECT as plugin action")
}
if !strings.Contains(err.Error(), "not SELECT") {
t.Errorf("expected 'not SELECT' error, got: %v", err)
}
}
func TestParsePluginConfig_LaneFilterParseError(t *testing.T) {
schema := testSchema()
cfg := pluginFileConfig{
Name: "Test",
Key: "T",
Lanes: []PluginLaneConfig{
{Name: "Bad", Filter: "totally invalid @@@"},
},
}
_, err := parsePluginConfig(cfg, "test.yaml", schema)
if err == nil {
t.Fatal("expected error for invalid filter expression")
}
if !strings.Contains(err.Error(), "parsing filter") {
t.Errorf("expected 'parsing filter' error, got: %v", err)
}
}
func TestParsePluginConfig_LaneActionParseError(t *testing.T) {
schema := testSchema()
cfg := pluginFileConfig{
Name: "Test",
Key: "T",
Lanes: []PluginLaneConfig{
{Name: "Bad", Filter: `select where status = "ready"`, Action: "totally invalid @@@"},
},
}
_, err := parsePluginConfig(cfg, "test.yaml", schema)
if err == nil {
t.Fatal("expected error for invalid action expression")
}
if !strings.Contains(err.Error(), "parsing action") {
t.Errorf("expected 'parsing action' error, got: %v", err)
}
}
func TestParsePluginConfig_LaneMissingName(t *testing.T) {
schema := testSchema()
cfg := pluginFileConfig{
Name: "Test",
Key: "T",
Lanes: []PluginLaneConfig{
{Name: "", Filter: `select where status = "ready"`},
},
}
_, err := parsePluginConfig(cfg, "test.yaml", schema)
if err == nil {
t.Fatal("expected error for lane missing name")
}
if !strings.Contains(err.Error(), "missing name") {
t.Errorf("expected 'missing name' error, got: %v", err)
}
}
func TestParsePluginConfig_LaneInvalidColumns(t *testing.T) {
schema := testSchema()
cfg := pluginFileConfig{
Name: "Test",
Key: "T",
Lanes: []PluginLaneConfig{
{Name: "Bad", Columns: -1, Filter: `select where status = "ready"`},
},
}
_, err := parsePluginConfig(cfg, "test.yaml", schema)
if err == nil {
t.Fatal("expected error for invalid columns")
}
if !strings.Contains(err.Error(), "invalid columns") {
t.Errorf("expected 'invalid columns' error, got: %v", err)
}
}
func TestParsePluginConfig_LaneInvalidWidth(t *testing.T) {
schema := testSchema()
cfg := pluginFileConfig{
Name: "Test",
Key: "T",
Lanes: []PluginLaneConfig{
{Name: "Bad", Width: 101, Filter: `select where status = "ready"`},
},
}
_, err := parsePluginConfig(cfg, "test.yaml", schema)
if err == nil {
t.Fatal("expected error for invalid width")
}
if !strings.Contains(err.Error(), "invalid width") {
t.Errorf("expected 'invalid width' error, got: %v", err)
}
}
func TestParsePluginConfig_TooManyLanes(t *testing.T) {
schema := testSchema()
lanes := make([]PluginLaneConfig, 11)
for i := range lanes {
lanes[i] = PluginLaneConfig{Name: "Lane", Filter: `select where status = "ready"`}
}
cfg := pluginFileConfig{
Name: "Test",
Key: "T",
Lanes: lanes,
}
_, err := parsePluginConfig(cfg, "test.yaml", schema)
if err == nil {
t.Fatal("expected error for too many lanes")
}
if !strings.Contains(err.Error(), "too many lanes") {
t.Errorf("expected 'too many lanes' error, got: %v", err)
}
}
func TestParsePluginConfig_NoLanes(t *testing.T) {
schema := testSchema()
cfg := pluginFileConfig{
Name: "Test",
Key: "T",
Type: "tiki",
Lanes: []PluginLaneConfig{},
}
_, err := parsePluginConfig(cfg, "test.yaml", schema)
if err == nil {
t.Fatal("expected error for no lanes")
}
if !strings.Contains(err.Error(), "requires 'lanes'") {
t.Errorf("expected 'requires lanes' error, got: %v", err)
}
}
func TestParsePluginConfig_TikiWithURL(t *testing.T) {
schema := testSchema()
cfg := pluginFileConfig{
Name: "Test",
Key: "T",
Type: "tiki",
URL: "http://example.com",
Lanes: []PluginLaneConfig{
{Name: "Todo", Filter: `select where status = "ready"`},
},
}
_, err := parsePluginConfig(cfg, "test.yaml", schema)
if err == nil {
t.Fatal("expected error for tiki with url")
}
if !strings.Contains(err.Error(), "tiki plugin cannot have 'url'") {
t.Errorf("expected 'cannot have url' error, got: %v", err)
}
}
func TestParsePluginConfig_DokiWithLanes(t *testing.T) {
schema := testSchema()
cfg := pluginFileConfig{
Name: "Test",
Key: "T",
Type: "doki",
Fetcher: "internal",
Text: "content",
Lanes: []PluginLaneConfig{{Name: "Bad"}},
}
_, err := parsePluginConfig(cfg, "test.yaml", schema)
if err == nil {
t.Fatal("expected error for doki with lanes")
}
if !strings.Contains(err.Error(), "doki plugin cannot have 'lanes'") {
t.Errorf("expected 'cannot have lanes' error, got: %v", err)
}
}
func TestParsePluginConfig_PluginActionsError(t *testing.T) {
schema := testSchema()
cfg := pluginFileConfig{
Name: "Test",
Key: "T",
Lanes: []PluginLaneConfig{
{Name: "Todo", Filter: `select where status = "ready"`},
},
Actions: []PluginActionConfig{
{Key: "b", Label: "Bad", Action: "totally invalid @@@"},
},
}
_, err := parsePluginConfig(cfg, "test.yaml", schema)
if err == nil {
t.Fatal("expected error for invalid plugin action")
}
}
func TestParsePluginActions_NonPrintableKey(t *testing.T) {
parser := testParser()
configs := []PluginActionConfig{
{Key: "\x01", Label: "Test", Action: `update where id = id() set status="ready"`},
}
_, err := parsePluginActions(configs, parser)
if err == nil {
t.Fatal("expected error for non-printable key")
}
if !strings.Contains(err.Error(), "printable character") {
t.Errorf("expected 'printable character' error, got: %v", err)
}
}
func TestParsePluginYAML_ValidDoki(t *testing.T) {
validYAML := []byte(`
name: Doc Plugin
@ -492,7 +717,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

@ -75,6 +75,88 @@ func TestValidatedStatement_RequiresCreateTemplate(t *testing.T) {
}
}
func TestValidatedStatement_IsSelect(t *testing.T) {
tests := []struct {
name string
vs *ValidatedStatement
want bool
}{
{"nil receiver", nil, false},
{"nil statement", &ValidatedStatement{statement: nil}, false},
{"select", &ValidatedStatement{statement: &Statement{Select: &SelectStmt{}}}, true},
{"update", &ValidatedStatement{statement: &Statement{Update: &UpdateStmt{}}}, false},
{"create", &ValidatedStatement{statement: &Statement{Create: &CreateStmt{}}}, false},
{"delete", &ValidatedStatement{statement: &Statement{Delete: &DeleteStmt{}}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.vs.IsSelect(); got != tt.want {
t.Errorf("IsSelect() = %v, want %v", got, tt.want)
}
})
}
}
func TestValidatedStatement_IsUpdate(t *testing.T) {
tests := []struct {
name string
vs *ValidatedStatement
want bool
}{
{"nil receiver", nil, false},
{"nil statement", &ValidatedStatement{statement: nil}, false},
{"update", &ValidatedStatement{statement: &Statement{Update: &UpdateStmt{}}}, true},
{"select", &ValidatedStatement{statement: &Statement{Select: &SelectStmt{}}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.vs.IsUpdate(); got != tt.want {
t.Errorf("IsUpdate() = %v, want %v", got, tt.want)
}
})
}
}
func TestValidatedStatement_IsCreate(t *testing.T) {
tests := []struct {
name string
vs *ValidatedStatement
want bool
}{
{"nil receiver", nil, false},
{"nil statement", &ValidatedStatement{statement: nil}, false},
{"create", &ValidatedStatement{statement: &Statement{Create: &CreateStmt{}}}, true},
{"select", &ValidatedStatement{statement: &Statement{Select: &SelectStmt{}}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.vs.IsCreate(); got != tt.want {
t.Errorf("IsCreate() = %v, want %v", got, tt.want)
}
})
}
}
func TestValidatedStatement_IsDelete(t *testing.T) {
tests := []struct {
name string
vs *ValidatedStatement
want bool
}{
{"nil receiver", nil, false},
{"nil statement", &ValidatedStatement{statement: nil}, false},
{"delete", &ValidatedStatement{statement: &Statement{Delete: &DeleteStmt{}}}, true},
{"select", &ValidatedStatement{statement: &Statement{Select: &SelectStmt{}}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.vs.IsDelete(); got != tt.want {
t.Errorf("IsDelete() = %v, want %v", got, tt.want)
}
})
}
}
func TestValidatedStatement_Accessors(t *testing.T) {
vs := &ValidatedStatement{
runtime: ExecutorRuntimePlugin,

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

View file

@ -25,7 +25,7 @@ func DrawSingleLineBorder(screen tcell.Screen, x, y, width, height int) {
}
colors := config.GetColors()
style := tcell.StyleDefault.Foreground(colors.TaskBoxUnselectedBorder).Background(config.GetContentBackgroundColor())
style := tcell.StyleDefault.Foreground(colors.TaskBoxUnselectedBorder).Background(colors.ContentBackgroundColor)
DrawSingleLineBorderWithStyle(screen, x, y, width, height, style)
}

View file

@ -57,17 +57,16 @@ 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"
```
that translates to - show all tikis in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single lane.
The `actions` section defines a keyboard shortcut `b` that moves the selected tiki to the board by setting its status to `ready`
You define the name, caption colors, hotkey, tiki filter and sorting. Save this into a `workflow.yaml` file in the config directory
You define the name, caption colors, hotkey, and `ruki` expressions for filtering and actions. Save this into a `workflow.yaml` file in the config directory
Likewise the documentation is just a plugin:
@ -98,28 +97,27 @@ name: Custom
foreground: "#5fff87"
background: "#005f00"
key: "F4"
sort: Priority, Title
lanes:
- name: Ready
columns: 1
width: 20
filter: status = 'ready'
action: status = 'ready'
filter: select where status = "ready" order by priority, title
action: update where id = id() set status="ready"
- name: In Progress
columns: 1
width: 30
filter: status = 'in_progress'
action: status = 'in_progress'
filter: select where status = "inProgress" order by priority, title
action: update where id = id() set status="inProgress"
- name: Review
columns: 1
width: 30
filter: status = 'review'
action: status = 'review'
filter: select where status = "review" order by priority, title
action: update where id = id() set status="review"
- name: Done
columns: 1
width: 20
filter: status = 'done'
action: status = 'done'
filter: select where status = "done" order by priority, title
action: update where id = id() set status="done"
```
### Lane width
@ -147,129 +145,90 @@ that apply to the currently selected tiki via a keyboard shortcut. These shortcu
actions:
- key: "b"
label: "Add to board"
action: status = 'ready'
action: update where id = id() set status="ready"
- key: "a"
label: "Assign to me"
action: assignee = CURRENT_USER
action: update where id = id() set assignee=user()
```
Each action has:
- `key` - a single printable character used as the keyboard shortcut
- `label` - description shown in the header
- `action` - an action expression (same syntax as lane actions, see below)
- `action` - a `ruki` `update` statement (same syntax as lane actions, see below)
When the shortcut key is pressed, the action is applied to the currently selected tiki.
For example, pressing `b` in the Backlog plugin changes the selected tiki's status to `ready`, effectively moving it to the board.
### Action expression
### ruki expressions
The `action: status = 'backlog'` statement in a plugin is an action to be run when a tiki is moved into the lane. Here `=`
means `assign` so status is assigned `backlog` when the tiki is moved. Likewise you can manipulate tags using `+-` (add)
or `-=` (remove) expressions. For example, `tags += [idea, UI]` adds `idea` and `UI` tags to a tiki
Plugin filters, lane actions, and plugin actions all use the `ruki` language. Filters use `select` statements and actions use `update` statements.
#### Supported Fields
#### Filter (select)
- `status` - set workflow status (must be a key defined in `workflow.yaml` statuses)
- `type` - set task type: `story`, `bug`, `spike`, `epic` (case-insensitive)
- `priority` - set numeric priority (1-5)
- `points` - set numeric points (0 or positive, up to max points)
- `assignee` - set assignee string
- `tags` - add/remove tags (list)
The `filter` field uses a `ruki` `select` statement to determine which tikis appear in a lane. Sorting is part of the select — use `order by` to control display order.
#### Operators
```sql
-- basic filter with sort
select where status = "backlog" and type != "epic" order by priority, id
- `=` assigns a value to `status`, `type`, `priority`, `points`, `assignee`
- `+=` adds tags, `-=` removes tags
- multiple operations are separated by commas: `status=done, tags+=[moved]`
-- recent items, most recent first
select where now() - updatedAt < 24hour order by updatedAt desc
#### Literals
-- multiple conditions
select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
- strings can be quoted (`'in_progress'`, `"alex"`) or bare (`done`, `alex`)
- use quotes when the value has spaces
- integers are used for `priority` and `points`
- tag lists use brackets: `tags += [ui, frontend]`
- `CURRENT_USER` assigns the current git user to `assignee`
- example: `assignee = CURRENT_USER`
### Filter expression
The `filter: status = 'backlog'` statement in a plugin is a filter expression that determines which tikis appear in the view.
#### Supported Fields
You can filter on these task fields:
- `id` - Task identifier (e.g., 'TIKI-m7n2xk')
- `title` - Task title text (case-insensitive)
- `type` - Task type: 'story', 'bug', 'spike', or 'epic' (case-insensitive)
- `status` - Workflow status (must match a key defined in `workflow.yaml` statuses)
- `assignee` - Assigned user (case-insensitive)
- `priority` - Numeric priority value
- `points` - Story points estimate
- `tags` (or `tag`) - List of tags (case-insensitive)
- `createdAt` - Creation timestamp
- `updatedAt` - Last update timestamp
All string comparisons are case-insensitive.
#### Operators
- **Comparison**: `=` (or `==`), `!=`, `>`, `>=`, `<`, `<=`
- **Logical**: `AND`, `OR`, `NOT` (precedence: NOT > AND > OR)
- **Membership**: `IN`, `NOT IN` (check if value in list using `[val1, val2]`)
- **Grouping**: Use parentheses `()` to control evaluation order
#### Literals and Special Values
**Special expressions**:
- `CURRENT_USER` - Resolves to the current git user (works in comparisons and IN lists)
- `NOW` - Current timestamp
**Time expressions**:
- `NOW - UpdatedAt` - Time elapsed since update
- `NOW - CreatedAt` - Time since creation
- Duration units: `min`/`minutes`, `hour`/`hours`, `day`/`days`, `week`/`weeks`, `month`/`months`
- Examples: `2hours`, `14days`, `3weeks`, `60min`, `1month`
- Operators: `+` (add), `-` (subtract or compute duration)
**Special tag semantics**:
- `tags IN ['ui', 'frontend']` matches if ANY task tag matches ANY list value
- This allows intersection testing across tag arrays
#### Examples
```text
# Multiple statuses
status = 'ready' OR status = 'in_progress'
# With tags
tags IN ['frontend', 'urgent']
# High priority bugs
type = 'bug' AND priority = 0
# Features and ideas assigned to me
(type = 'feature' OR tags IN ['idea']) AND assignee = CURRENT_USER
# Unassigned large tasks
assignee = '' AND points >= 5
# Recently created tasks not in backlog
(NOW - CreatedAt < 2hours) AND status != 'backlog'
-- assigned to me
select where assignee = user() order by priority
```
### Sorting
#### Action (update)
The `sort` field determines the order in which tikis appear in the view. You can sort by one or more fields, and control the direction (ascending or descending).
The `action` field uses a `ruki` `update` statement. In plugin context, `id()` refers to the currently selected tiki.
#### Sort Syntax
```sql
-- set status on move
update where id = id() set status="ready"
```text
sort: Field1, Field2 DESC, Field3
-- set multiple fields
update where id = id() set status="backlog" priority=2
-- assign to current user
update where id = id() set assignee=user()
```
#### Examples
#### Supported fields
```text
# Sort by creation time descending (recent first), then priority, then title
sort: CreatedAt DESC, Priority, Title
```
- `id` - task identifier (e.g., "TIKI-M7N2XK")
- `title` - task title text
- `type` - task type: "story", "bug", "spike", or "epic"
- `status` - workflow status (must match a key defined in `workflow.yaml` statuses)
- `assignee` - assigned user
- `priority` - numeric priority value (1-5)
- `points` - story points estimate
- `tags` - list of tags
- `dependsOn` - list of dependency tiki IDs
- `due` - due date (YYYY-MM-DD format)
- `recurrence` - recurrence pattern (cron format)
- `createdAt` - creation timestamp
- `updatedAt` - last update timestamp
#### Conditions
- **Comparison**: `=`, `!=`, `>`, `>=`, `<`, `<=`
- **Logical**: `and`, `or`, `not` (precedence: not > and > or)
- **Membership**: `"value" in field`, `status not in ["done", "cancelled"]`
- **Emptiness**: `assignee is empty`, `tags is not empty`
- **Quantifiers**: `dependsOn any status != "done"`, `dependsOn all status = "done"`
- **Grouping**: parentheses `()` to control evaluation order
#### Literals and built-ins
- Strings: double-quoted (`"ready"`, `"alex"`)
- Integers: `1`, `5`
- Dates: `2026-03-25`
- Durations: `2hour`, `14day`, `3week`, `1month`
- Lists: `["bug", "frontend"]`
- `user()` — current git user
- `now()` — current timestamp
- `id()` — currently selected tiki (in plugin context)
- `count(select where ...)` — count matching tikis

View file

@ -47,7 +47,7 @@ func NewNavigableMarkdown(cfg NavigableMarkdownConfig) *NavigableMarkdown {
renderer = renderer.WithCodeBorder(b)
}
nm.viewer.SetRenderer(renderer)
nm.viewer.SetBackgroundColor(config.GetContentBackgroundColor())
nm.viewer.SetBackgroundColor(config.GetColors().ContentBackgroundColor)
if cfg.ImageManager != nil && cfg.ImageManager.Supported() {
nm.viewer.SetImageManager(cfg.ImageManager)
}

View file

@ -22,8 +22,8 @@ func NewSearchBox() *SearchBox {
// Configure the input field (border drawn manually in Draw)
inputField.SetLabel("> ")
inputField.SetLabelColor(colors.SearchBoxLabelColor)
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
inputField.SetFieldTextColor(config.GetContentTextColor())
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor)
inputField.SetFieldTextColor(colors.ContentTextColor)
inputField.SetBorder(false)
sb := &SearchBox{
@ -60,7 +60,7 @@ func (sb *SearchBox) Draw(screen tcell.Screen) {
}
// Fill interior with theme-aware background color
bgColor := config.GetContentBackgroundColor()
bgColor := config.GetColors().ContentBackgroundColor
bgStyle := tcell.StyleDefault.Background(bgColor)
for row := y; row < y+height; row++ {
for col := x; col < x+width; col++ {

View file

@ -30,7 +30,7 @@ func applyFrameStyle(frame *tview.Frame, selected bool, colors *config.ColorConf
// buildCompactTaskContent builds the content string for compact task display
func buildCompactTaskContent(task *taskpkg.Task, colors *config.ColorConfig, availableWidth int) string {
emoji := taskpkg.TypeEmoji(task.Type)
idGradient := gradient.RenderAdaptiveGradientText(task.ID, colors.TaskBoxIDColor, config.FallbackTaskIDColor)
idGradient := gradient.RenderAdaptiveGradientText(task.ID, colors.TaskBoxIDColor, colors.FallbackTaskIDColor)
truncatedTitle := tview.Escape(util.TruncateText(task.Title, availableWidth))
priorityEmoji := taskpkg.PriorityLabel(task.Priority)
pointsVisual := util.GeneratePointsVisual(task.Points, config.GetMaxPoints(), colors.PointsFilledColor, colors.PointsUnfilledColor)
@ -45,7 +45,7 @@ func buildCompactTaskContent(task *taskpkg.Task, colors *config.ColorConfig, ava
// buildExpandedTaskContent builds the content string for expanded task display
func buildExpandedTaskContent(task *taskpkg.Task, colors *config.ColorConfig, availableWidth int) string {
emoji := taskpkg.TypeEmoji(task.Type)
idGradient := gradient.RenderAdaptiveGradientText(task.ID, colors.TaskBoxIDColor, config.FallbackTaskIDColor)
idGradient := gradient.RenderAdaptiveGradientText(task.ID, colors.TaskBoxIDColor, colors.FallbackTaskIDColor)
truncatedTitle := tview.Escape(util.TruncateText(task.Title, availableWidth))
// Extract first 3 lines of description

View file

@ -114,7 +114,7 @@ func (b *Base) assembleMetadataBox(
metadataBox := tview.NewFrame(metadataContainer).SetBorders(0, 0, 0, 0, 0, 0)
metadataBox.SetBorder(true).SetTitle(
fmt.Sprintf(" %s ", gradient.RenderAdaptiveGradientText(task.ID, colors.TaskDetailIDColor, config.FallbackTaskIDColor)),
fmt.Sprintf(" %s ", gradient.RenderAdaptiveGradientText(task.ID, colors.TaskDetailIDColor, colors.FallbackTaskIDColor)),
).SetBorderColor(colors.TaskBoxUnselectedBorder)
metadataBox.SetBorderPadding(1, 0, 2, 2)

View file

@ -381,7 +381,7 @@ func (ev *TaskEditView) ensureTagsTextArea(task *taskpkg.Task) *tview.TextArea {
ev.tagsTextArea.SetBorder(false)
ev.tagsTextArea.SetBorderPadding(1, 1, 2, 2)
ev.tagsTextArea.SetPlaceholder("Enter tags separated by spaces")
ev.tagsTextArea.SetPlaceholderStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
ev.tagsTextArea.SetPlaceholderStyle(tcell.StyleDefault.Foreground(config.GetColors().TaskDetailPlaceholderColor))
ev.tagsTextArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyCtrlS {
@ -448,7 +448,7 @@ func (ev *TaskEditView) ensureTitleInput(task *taskpkg.Task) *tview.InputField {
if ev.titleInput == nil {
colors := config.GetColors()
ev.titleInput = tview.NewInputField()
ev.titleInput.SetFieldBackgroundColor(config.GetContentBackgroundColor())
ev.titleInput.SetFieldBackgroundColor(colors.ContentBackgroundColor)
ev.titleInput.SetFieldTextColor(colors.InputFieldTextColor)
ev.titleInput.SetBorder(false)