custom fields

This commit is contained in:
booleanmaybe 2026-04-15 18:32:55 -04:00
parent 3b33659338
commit 3f86eb93ce
36 changed files with 4001 additions and 171 deletions

View file

@ -0,0 +1,249 @@
# Custom Fields
## Table of contents
- [Overview](#overview)
- [Defining custom fields](#defining-custom-fields)
- [Field types](#field-types)
- [Enum fields](#enum-fields)
- [Using custom fields in ruki](#using-custom-fields-in-ruki)
- [Storage and frontmatter](#storage-and-frontmatter)
- [Templates](#templates)
- [Missing field behavior](#missing-field-behavior)
## Overview
Custom fields let you extend tikis with project-specific data beyond the built-in fields (title, status, priority, etc.). Define them in `workflow.yaml` and they become first-class citizens: usable in ruki queries, persisted in task frontmatter, and available across all views.
Use cases include:
- tracking a sprint or milestone name
- adding an effort estimate or story-point alternative
- flagging tasks with a boolean (e.g. `blocked`, `reviewed`)
- recording a deadline timestamp with time-of-day precision
- categorizing tasks with a constrained set of values (enum)
- linking related tasks beyond `dependsOn`
## Defining custom fields
Add a `fields:` section to your `workflow.yaml`:
```yaml
fields:
- name: sprint
type: text
- name: effort
type: integer
- name: blocked
type: boolean
- name: deadline
type: datetime
- name: category
type: enum
values:
- frontend
- backend
- infra
- docs
- name: reviewers
type: stringList
- name: relatedTasks
type: taskIdList
```
Field names must not collide with built-in field names or ruki reserved keywords.
Custom fields follow the same merge semantics as other `workflow.yaml` sections. If the same field is defined identically in multiple files (user config, project config, cwd), the duplicate is silently accepted. If definitions conflict (different type or different enum values), loading fails with an error.
## Field types
| YAML type | Description | ruki type |
|---------------|---------------------------------------|------------------|
| `text` | free-form string | `string` |
| `integer` | whole number | `int` |
| `boolean` | true or false | `bool` |
| `datetime` | timestamp (RFC3339 or YYYY-MM-DD) | `timestamp` |
| `enum` | constrained string from `values` list | `enum` |
| `stringList` | list of strings | `list<string>` |
| `taskIdList` | list of tiki ID references | `list<ref>` |
## Enum fields
Enum fields require a `values:` list. Only those values are accepted when setting the field (case-insensitive matching, canonical casing preserved). Attempting to assign a value outside the list produces a validation error.
```yaml
fields:
- name: severity
type: enum
values:
- critical
- major
- minor
- trivial
```
Enum domains are field-scoped: two different enum fields maintain independent value sets. Cross-field enum assignment (e.g. `set severity = category`) is rejected even if the values happen to overlap.
Non-enum fields must not include a `values:` list.
## Using custom fields in ruki
Custom fields work the same as built-in fields in all ruki contexts: `select`, `update`, `create`, `order by`, `where`, and triggers.
### Filtering with select where
```sql
-- find blocked tasks
select where blocked = true
-- find tasks in a specific sprint
select where sprint = "sprint-7"
-- find critical tasks in the frontend category
select where severity = "critical" and category = "frontend"
-- find tasks with high effort
select where effort > 5
-- find tasks with a deadline before a date
select where deadline < 2026-05-01
```
### Updating with update set
```sql
-- assign a sprint
update where id = id() set sprint="sprint-7"
-- mark as blocked
update where id = id() set blocked=true
-- set category and severity
update where id = id() set category="backend" severity="major"
-- clear a custom field (set to empty)
update where id = id() set sprint=empty
-- add a reviewer
update where id = id() set reviewers=reviewers + ["alice"]
```
### Ordering with order by
```sql
-- sort by effort descending
select where status = "ready" order by effort desc
-- sort by category, then priority
select where status = "backlog" order by category, priority
-- sort by deadline
select where deadline is not empty order by deadline
```
### Creating with custom field defaults
```sql
-- create a task with custom fields
create title="New feature" category="frontend" effort=3
-- create with enum and boolean
create title="Fix crash" severity="critical" blocked=false
```
### Plugin filters and actions
Custom fields integrate into plugin definitions in `workflow.yaml`:
```yaml
views:
plugins:
- name: Sprint Board
key: "F5"
lanes:
- name: Current Sprint
filter: select where sprint = "sprint-7" and status != "done" order by effort desc
action: update where id = id() set sprint="sprint-7"
- name: Next Sprint
filter: select where sprint = "sprint-8" order by priority
action: update where id = id() set sprint="sprint-8"
actions:
- key: "b"
label: "Mark blocked"
action: update where id = id() set blocked=true
```
## Storage and frontmatter
Custom fields are stored in task frontmatter alongside built-in fields:
```yaml
---
title: Implement search
type: story
status: in_progress
priority: 2
points: 3
tags:
- search
sprint: sprint-7
blocked: false
category: backend
effort: 5
deadline: 2026-05-15T17:00:00Z
reviewers:
- alice
- bob
relatedTasks:
- TIKI-ABC123
---
Search implementation details...
```
Custom fields appear after the built-in fields, sorted alphabetically by name.
On load, unknown frontmatter keys that are not registered custom fields are preserved as-is and survive save-load round-trips. This allows workflow changes without losing data — see [Schema evolution](ruki/custom-fields-reference.md#schema-evolution-and-stale-data) for details.
## Templates
Custom fields can have defaults in `new.md`:
```markdown
---
type: story
status: backlog
priority: 3
points: 1
sprint: sprint-7
blocked: false
category: backend
---
```
Custom field values in the template are validated against their type definitions and enum constraints, the same as in task files.
## Missing field behavior
When a custom field is not set on a task, ruki returns the typed zero value for that field's type:
| Field type | Zero value |
|---------------|--------------------|
| `text` | `""` (empty string)|
| `integer` | `0` |
| `boolean` | `false` |
| `datetime` | zero time |
| `enum` | `""` (empty string)|
| `stringList` | `[]` (empty list) |
| `taskIdList` | `[]` (empty list) |
This means `select where blocked = false` matches both tasks explicitly set to `false` and tasks that never had the `blocked` field set. Use `is empty` / `is not empty` to distinguish:
```sql
-- tasks that explicitly have blocked set (to any value)
select where blocked is not empty
-- tasks where blocked was never set
select where blocked is empty
```
Note: for boolean and integer fields, the zero value (`false`, `0`) is also the `empty` value. An explicitly stored `false` and a missing boolean field are indistinguishable at query time. If you need the distinction, consider using an enum field with explicit values (e.g. `yes` / `no` / not set via `empty`).

View file

@ -11,6 +11,9 @@
- [Recent ideas](#recent-ideas--good-or-trash)
- [Auto-delete stale tasks](#auto-delete-stale-tasks--time-trigger)
- [Priority triage](#priority-triage--five-lane-plugin)
- [Sprint board](#sprint-board--custom-enum-lanes)
- [Severity triage](#severity-triage--custom-enum-filter--action)
- [Subtasks in epic](#subtasks-in-epic--custom-taskidlist--quantifier-trigger)
- [By topic](#by-topic--tag-based-lanes)
## Assign to me — global plugin action
@ -199,6 +202,109 @@ One lane per priority level. Moving a task between lanes reassigns its priority.
action: update where id = id() set priority=5
```
## Sprint board — custom enum lanes
Uses a custom `sprint` enum field. Lanes per sprint; moving a task between lanes reassigns it. The third lane catches unplanned backlog tasks.
Requires:
```yaml
fields:
- name: sprint
type: enum
values: [sprint-7, sprint-8, sprint-9]
```
```yaml
- name: Sprint Board
key: "F9"
lanes:
- name: Current Sprint
filter: select where sprint = "sprint-7" and status != "done" order by priority
action: update where id = id() set sprint="sprint-7"
- name: Next Sprint
filter: select where sprint = "sprint-8" order by priority
action: update where id = id() set sprint="sprint-8"
- name: Unplanned
filter: select where sprint is empty and status = "backlog" order by priority
action: update where id = id() set sprint=empty
```
## Severity triage — custom enum filter + action
Lanes per severity level. The last lane combines two values with `or`. A per-plugin action lets you mark a task as trivial without moving it.
Requires:
```yaml
fields:
- name: severity
type: enum
values: [critical, major, minor, trivial]
```
```yaml
- name: Severity
key: "F10"
lanes:
- name: Critical
filter: select where severity = "critical" order by updatedAt desc
action: update where id = id() set severity="critical"
- name: Major
filter: select where severity = "major" order by updatedAt desc
action: update where id = id() set severity="major"
- name: Minor & Trivial
columns: 2
filter: >
select where severity = "minor" or severity = "trivial"
order by severity, priority
action: update where id = id() set severity="minor"
actions:
- key: "t"
label: "Trivial"
action: update where id = id() set severity="trivial"
```
## Subtasks in epic — custom taskIdList + quantifier trigger
A `subtasks` field on parent tasks tracks their children (inverse of `dependsOn`). A trigger auto-completes the parent when every subtask is done. The plugin shows open vs. completed parents.
Requires:
```yaml
fields:
- name: subtasks
type: taskIdList
```
```yaml
triggers:
- description: close parent when all subtasks are done
ruki: >
every 5min
update where subtasks is not empty
and status != "done"
and all subtasks where status = "done"
set status="done"
```
```yaml
- name: Epics
key: "F11"
lanes:
- name: In Progress
filter: >
select where subtasks is not empty
and status != "done"
order by priority
- name: Completed
columns: 1
filter: >
select where subtasks is not empty
and status = "done"
order by updatedAt desc
```
## By topic — tag-based lanes
Split tasks into lanes by tag. Useful for viewing work across domains at a glance.

View file

@ -4,6 +4,7 @@
- [Installation](install.md)
- [Markdown viewer](markdown-viewer.md)
- [Image support](image-requirements.md)
- [Custom fields](custom-fields.md)
- [Customization](customization.md)
- [Themes](themes.md)
- [ruki](ruki/index.md)

View file

@ -0,0 +1,175 @@
# Custom Fields Reference
## Table of contents
- [Overview](#overview)
- [Registration and loading](#registration-and-loading)
- [Naming constraints](#naming-constraints)
- [Type coercion rules](#type-coercion-rules)
- [Enum domain isolation](#enum-domain-isolation)
- [Validation rules](#validation-rules)
- [Persistence and round-trip behavior](#persistence-and-round-trip-behavior)
- [Schema evolution and stale data](#schema-evolution-and-stale-data)
- [Template defaults](#template-defaults)
- [Query behavior](#query-behavior)
- [Missing-field semantics](#missing-field-semantics)
## Overview
Custom fields extend tiki's built-in field catalog with user-defined fields declared in `workflow.yaml`. This reference covers the precise rules for how custom fields are loaded, validated, persisted, and queried — the behavioral contract behind the [Custom Fields](../custom-fields.md) user guide.
## Registration and loading
Custom field definitions are loaded from all `workflow.yaml` files across the three-tier search path (user config, project config, working directory). Files that define `fields:` but no `views:` still contribute field definitions.
Definitions from multiple files are merged by name:
- **identical redefinition** (same name, same type, same enum values in same order): silently accepted
- **conflicting redefinition** (same name, different type or values): fatal error naming both source files
After merging, fields are sorted by name for deterministic ordering and registered into the field catalog alongside built-in fields. Once registered, custom fields are available for ruki parsing, validation, and execution.
Registration happens during bootstrap before any task or template loading occurs.
## Naming constraints
Field names must:
- match the ruki identifier pattern (letters, digits, underscores; must start with a letter)
- not collide with ruki reserved keywords (`select`, `update`, `where`, `and`, `or`, `not`, `in`, `is`, `empty`, `order`, `by`, `asc`, `desc`, `set`, `create`, `delete`, `limit`, etc.)
- not collide with built-in field names, case-insensitively (`title`, `status`, `priority`, `tags`, `dependsOn`, etc.)
- not be `true` or `false` (reserved boolean literals)
Collision checks are case-insensitive: `Status` and `STATUS` both collide with the built-in `status`.
## Type coercion rules
When custom field values are read from frontmatter YAML, they are coerced to the expected type:
| Field type | Accepted YAML values | Coercion behavior |
|---------------|-----------------------------------------------|--------------------------------------------------|
| `text` | string | pass-through |
| `enum` | string | case-insensitive match against allowed values; stored in canonical casing |
| `integer` | integer or decimal number | integer pass-through; decimal accepted only if it represents a whole number (e.g. `3.0``3`, but `1.5` → error) |
| `boolean` | `true` / `false` | pass-through |
| `datetime` | timestamp or date string | native YAML timestamps pass through; strings parsed as RFC3339, with fallback to `YYYY-MM-DD` |
| `stringList` | YAML list of strings | each element must be a string |
| `taskIdList` | YAML list of strings | each element coerced to uppercase, whitespace trimmed, empty entries dropped |
## Enum domain isolation
Each enum field maintains its own independent set of allowed values. Two enum fields never share a domain, even if their values happen to overlap.
This isolation is enforced at three levels:
- **assignment**: `set severity = category` is rejected even if both are enum fields
- **comparison**: `where severity = category` is rejected (comparing different enum domains)
- **in-expression**: string literals in an `in` list are validated against the specific enum field's allowed values
Enum comparison is case-insensitive: `category = "Backend"` matches a stored `"backend"`.
## Validation rules
Custom fields follow the same validation pipeline as built-in fields:
- **type compatibility**: assignments and comparisons are type-checked (e.g. you cannot assign a string to an integer field, or compare an enum field with an integer literal)
- **enum value validation**: string literals assigned to or compared against an enum field must be in that field's allowed values
- **ordering**: custom fields of orderable types (`text`, `integer`, `boolean`, `datetime`, `enum`) can appear in `order by` clauses; list types (`stringList`, `taskIdList`) are not orderable
## Persistence and round-trip behavior
Custom fields are stored in task file frontmatter alongside built-in fields. When a task is saved:
- custom fields appear after built-in fields, sorted alphabetically by name
- values that look ambiguous in YAML (e.g. a text field containing `"true"`, `"42"`, or `"2026-05-15"`) are quoted to prevent YAML type coercion from corrupting them on reload
A save-then-load cycle preserves custom field values exactly. This holds as long as:
- the field definitions in `workflow.yaml` have not changed between save and load
- enum values use canonical casing (enforced automatically by coercion)
- timestamps round-trip through RFC3339 format
## Schema evolution and stale data
When `workflow.yaml` changes — fields renamed, enum values added or removed, field types changed — existing task files may contain values that no longer match the current schema. tiki handles this gracefully:
### Removed fields
If a frontmatter key no longer matches any registered custom field, it is preserved as an **unknown field**. Unknown fields survive load-save round-trips: they are written back to the file exactly as found. This allows manual cleanup or re-registration without data loss.
### Stale enum values
If a task file contains an enum value that is no longer in the field's allowed values list (e.g. `severity: critical` after `critical` was removed from the enum), the value is **demoted to an unknown field** with a warning. The task still loads and remains visible in views. The stale value is preserved in the file for repair.
### Type mismatches
If a value cannot be coerced to the field's current type (e.g. a text value `"not_a_number"` in a field that was changed to `integer`), the same demotion-to-unknown behavior applies: the task loads, the value is preserved, a warning is logged.
### General principle
tiki reads leniently and writes strictly. On load, unrecognized or incompatible values are preserved rather than rejected. On save, values are validated against the current schema.
## Template defaults
Custom fields in `new.md` templates follow the same coercion and validation rules as task files. If a template contains a value that cannot be coerced (e.g. a type mismatch), the invalid field is dropped with a warning and the template otherwise loads normally.
Template custom field values are copied into new tasks created via `create` statements or the new-task UI flow.
## Query behavior
Custom fields behave identically to built-in fields in ruki queries:
- usable in `where`, `order by`, `set`, `create`, and `select` field lists
- support `is empty` / `is not empty` checks
- support `in` / `not in` for list membership
- list-type fields support `+` (append) and `-` (remove) operations
- quantifiers (`any ... where`, `all ... where`) work on custom `taskIdList` fields
### Unset list fields
Unset custom list fields (`stringList`, `taskIdList`) behave the same as empty built-in list fields (`tags`, `dependsOn`). They return an empty list, not an absent value. This means:
- `"x" in labels` evaluates to `false` (not an error)
- `labels + ["new"]` produces `["new"]` (not an error)
- `labels is empty` evaluates to `true`
## Missing-field semantics
When a custom field has never been set on a task, ruki returns the typed zero value:
| Field type | Zero value |
|---------------|--------------------|
| `text` | `""` (empty string)|
| `integer` | `0` |
| `boolean` | `false` |
| `datetime` | zero time |
| `enum` | `""` (empty string)|
| `stringList` | `[]` (empty list) |
| `taskIdList` | `[]` (empty list) |
Setting a field to `empty` removes it entirely, making it indistinguishable from a field that was never set.
### Distinguishability by type
**Enum fields** preserve the missing-vs-set distinction: `""` is not a valid enum member, so `category is empty` only matches tasks where the field was never set (or was cleared). A task with `category = "frontend"` is never empty.
**Boolean and integer fields** do not preserve this distinction: `false` and `0` are both the zero value and the `empty` value. If you need to tell "never set" from "explicitly false" or "explicitly zero", use an enum field with named values (e.g. `yes` / `no`) instead.
### Worked examples
Suppose `blocked` is a custom boolean field:
| Query | never set | `false` | `true` |
|------------------------------------|-----------|---------|--------|
| `select where blocked = true` | no | no | yes |
| `select where blocked = false` | yes | yes | no |
| `select where blocked is empty` | yes | yes | no |
| `select where blocked is not empty`| no | no | yes |
Suppose `category` is a custom enum field with values `[frontend, backend, infra]`:
| Query | never set | `"frontend"` |
|--------------------------------------|-----------|--------------|
| `select where category = "frontend"` | no | yes |
| `select where category is empty` | yes | no |
| `select where category is not empty` | no | yes |

View file

@ -29,4 +29,5 @@ ready-to-use examples for common workflow patterns
- [Types And Values](types-and-values.md): value categories, literals, `empty`, enums, and schema-dependent typing.
- [Operators And Built-ins](operators-and-builtins.md): precedence, operators, built-in functions, and shell-adjacent capabilities.
- [Validation And Errors](validation-and-errors.md): parse errors, validation failures, edge cases, and strictness rules.
- [Custom Fields Reference](custom-fields-reference.md): coercion rules, enum isolation, persistence round-trips, schema evolution, and missing-field semantics.

272
config/fields.go Normal file
View file

@ -0,0 +1,272 @@
package config
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"github.com/boolean-maybe/tiki/workflow"
"gopkg.in/yaml.v3"
)
// customFieldYAML represents a single field entry in the workflow.yaml fields: section.
type customFieldYAML struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Values []string `yaml:"values,omitempty"` // enum only
}
// customFieldFileData is the minimal YAML structure for reading fields from workflow.yaml.
type customFieldFileData struct {
Fields []customFieldYAML `yaml:"fields"`
}
// registriesLoaded tracks whether LoadWorkflowRegistries has been called.
var registriesLoaded atomic.Bool
// RequireWorkflowRegistriesLoaded returns an error if LoadWorkflowRegistries
// (or LoadStatusRegistry + LoadCustomFields) has not been called yet.
// Intended for use by store/template code that needs registries to be ready
// but should not auto-load them from disk.
func RequireWorkflowRegistriesLoaded() error {
if !registriesLoaded.Load() {
return fmt.Errorf("workflow registries not loaded; call config.LoadWorkflowRegistries() first")
}
return nil
}
// MarkRegistriesLoadedForTest sets the registriesLoaded flag without loading
// from disk. Use in tests that call workflow.RegisterCustomFields directly.
func MarkRegistriesLoadedForTest() {
registriesLoaded.Store(true)
}
// ResetRegistriesLoadedForTest clears the registriesLoaded flag.
// Use in tests that need to verify the unloaded-registry error path.
func ResetRegistriesLoadedForTest() {
registriesLoaded.Store(false)
}
// LoadWorkflowRegistries is the shared startup helper that loads all
// workflow-registry-based sections (statuses, types, custom fields) from
// workflow.yaml files. Callers must build a fresh ruki.Schema after this returns.
func LoadWorkflowRegistries() error {
if err := LoadStatusRegistry(); err != nil {
return err
}
if err := LoadCustomFields(); err != nil {
return err
}
registriesLoaded.Store(true)
return nil
}
// LoadCustomFields reads the fields: section from all workflow.yaml files,
// validates and merges definitions, and registers them with workflow.RegisterCustomFields.
// Uses FindRegistryWorkflowFiles (no views filtering) so files with empty views:
// still contribute custom field definitions.
// Merge semantics: identical redefinitions allowed, conflicting redefinitions error.
func LoadCustomFields() error {
files := FindRegistryWorkflowFiles()
if len(files) == 0 {
// no workflow files at all — no custom fields to register, clear any stale state
workflow.ClearCustomFields()
return nil
}
// collect all field definitions with their source file
type fieldSource struct {
def customFieldYAML
file string
}
var allFields []fieldSource
for _, path := range files {
defs, err := readCustomFieldsFromFile(path)
if err != nil {
return fmt.Errorf("reading custom fields from %s: %w", path, err)
}
for _, d := range defs {
allFields = append(allFields, fieldSource{def: d, file: path})
}
}
if len(allFields) == 0 {
workflow.ClearCustomFields()
return nil
}
// merge: identical definitions allowed, conflicting definitions error
type mergedField struct {
def workflow.FieldDef
sourceFile string
}
merged := make(map[string]*mergedField)
for _, fs := range allFields {
def, err := convertCustomFieldDef(fs.def)
if err != nil {
return fmt.Errorf("field %q in %s: %w", fs.def.Name, fs.file, err)
}
if existing, ok := merged[def.Name]; ok {
if !fieldDefsEqual(existing.def, def) {
return fmt.Errorf("conflicting definition for custom field %q: defined differently in %s and %s",
def.Name, existing.sourceFile, fs.file)
}
// identical redefinition — skip
continue
}
merged[def.Name] = &mergedField{def: def, sourceFile: fs.file}
}
// build ordered slice for registration
defs := make([]workflow.FieldDef, 0, len(merged))
for _, m := range merged {
defs = append(defs, m.def)
}
// sort by name for deterministic ordering
sort.Slice(defs, func(i, j int) bool {
return defs[i].Name < defs[j].Name
})
if err := workflow.RegisterCustomFields(defs); err != nil {
return fmt.Errorf("registering custom fields: %w", err)
}
slog.Debug("loaded custom fields", "count", len(defs))
return nil
}
// FindRegistryWorkflowFiles returns all workflow.yaml files that exist,
// without the views-filtering that FindWorkflowFiles applies.
// Used by registry loaders (statuses, custom fields) that need to read
// configuration sections regardless of whether the file defines views.
func FindRegistryWorkflowFiles() []string {
pm := mustGetPathManager()
candidates := []string{
pm.UserConfigWorkflowFile(),
filepath.Join(pm.ProjectConfigDir(), defaultWorkflowFilename),
defaultWorkflowFilename, // relative to cwd
}
var result []string
seen := make(map[string]bool)
for _, path := range candidates {
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
if seen[abs] {
continue
}
if _, err := os.Stat(path); err != nil {
continue
}
seen[abs] = true
result = append(result, path)
}
return result
}
// readCustomFieldsFromFile reads the fields: section from a single workflow.yaml.
func readCustomFieldsFromFile(path string) ([]customFieldYAML, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var fd customFieldFileData
if err := yaml.Unmarshal(data, &fd); err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
return fd.Fields, nil
}
// convertCustomFieldDef converts a YAML field definition to a workflow.FieldDef.
func convertCustomFieldDef(def customFieldYAML) (workflow.FieldDef, error) {
if def.Name == "" {
return workflow.FieldDef{}, fmt.Errorf("field name is required")
}
if err := workflow.ValidateFieldName(def.Name); err != nil {
return workflow.FieldDef{}, err
}
vt, err := parseFieldType(def.Type)
if err != nil {
return workflow.FieldDef{}, err
}
fd := workflow.FieldDef{
Name: def.Name,
Type: vt,
Custom: true,
}
if vt == workflow.TypeEnum {
if len(def.Values) == 0 {
return workflow.FieldDef{}, fmt.Errorf("enum field requires non-empty values list")
}
fd.AllowedValues = make([]string, len(def.Values))
copy(fd.AllowedValues, def.Values)
} else if len(def.Values) > 0 {
return workflow.FieldDef{}, fmt.Errorf("values list is only valid for enum fields")
}
return fd, nil
}
// parseFieldType maps workflow.yaml type strings to workflow.ValueType.
func parseFieldType(s string) (workflow.ValueType, error) {
switch strings.ToLower(s) {
case "text":
return workflow.TypeString, nil
case "integer":
return workflow.TypeInt, nil
case "boolean":
return workflow.TypeBool, nil
case "datetime":
return workflow.TypeTimestamp, nil
case "enum":
return workflow.TypeEnum, nil
case "stringlist":
return workflow.TypeListString, nil
case "taskidlist":
return workflow.TypeListRef, nil
default:
return 0, fmt.Errorf("unknown field type %q (valid: text, integer, boolean, datetime, enum, stringList, taskIdList)", s)
}
}
// fieldDefsEqual returns true if two FieldDefs are structurally identical
// (same name, same type, and for enums, same normalized values).
func fieldDefsEqual(a, b workflow.FieldDef) bool {
if a.Name != b.Name || a.Type != b.Type {
return false
}
if a.Type == workflow.TypeEnum {
if len(a.AllowedValues) != len(b.AllowedValues) {
return false
}
// require exact spelling and order for duplicate enum declarations
for i := range a.AllowedValues {
if a.AllowedValues[i] != b.AllowedValues[i] {
return false
}
}
}
return true
}

225
config/fields_test.go Normal file
View file

@ -0,0 +1,225 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/boolean-maybe/tiki/workflow"
)
// setupLoadCustomFieldsTest creates temp dirs and configures the path manager
// so LoadCustomFields can discover workflow.yaml files.
func setupLoadCustomFieldsTest(t *testing.T) (cwdDir string) {
t.Helper()
workflow.ClearCustomFields()
t.Cleanup(func() { workflow.ClearCustomFields() })
userDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", userDir)
if err := os.MkdirAll(filepath.Join(userDir, "tiki"), 0750); err != nil {
t.Fatal(err)
}
projectDir := t.TempDir()
docDir := filepath.Join(projectDir, ".doc")
if err := os.MkdirAll(docDir, 0750); err != nil {
t.Fatal(err)
}
cwdDir = t.TempDir()
originalDir, _ := os.Getwd()
t.Cleanup(func() { _ = os.Chdir(originalDir) })
_ = os.Chdir(cwdDir)
ResetPathManager()
pm := mustGetPathManager()
pm.projectRoot = projectDir
return cwdDir
}
func TestLoadCustomFields_BasicTypes(t *testing.T) {
cwdDir := setupLoadCustomFieldsTest(t)
content := `
fields:
- name: notes
type: text
- name: score
type: integer
- name: active
type: boolean
- name: startedAt
type: datetime
- name: labels
type: stringList
- name: related
type: taskIdList
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
if err := LoadCustomFields(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
checks := []struct {
name string
wantType workflow.ValueType
}{
{"notes", workflow.TypeString},
{"score", workflow.TypeInt},
{"active", workflow.TypeBool},
{"startedAt", workflow.TypeTimestamp},
{"labels", workflow.TypeListString},
{"related", workflow.TypeListRef},
}
for _, c := range checks {
f, ok := workflow.Field(c.name)
if !ok {
t.Errorf("Field(%q) not found", c.name)
continue
}
if f.Type != c.wantType {
t.Errorf("Field(%q).Type = %v, want %v", c.name, f.Type, c.wantType)
}
if !f.Custom {
t.Errorf("Field(%q).Custom = false, want true", c.name)
}
}
}
func TestLoadCustomFields_EnumWithValues(t *testing.T) {
cwdDir := setupLoadCustomFieldsTest(t)
content := `
fields:
- name: severity
type: enum
values: [low, medium, high, critical]
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
if err := LoadCustomFields(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
f, ok := workflow.Field("severity")
if !ok {
t.Fatal("severity field not found")
}
if f.Type != workflow.TypeEnum {
t.Errorf("severity.Type = %v, want TypeEnum", f.Type)
}
wantVals := []string{"low", "medium", "high", "critical"}
if len(f.AllowedValues) != len(wantVals) {
t.Fatalf("severity.AllowedValues length = %d, want %d", len(f.AllowedValues), len(wantVals))
}
for i, v := range wantVals {
if f.AllowedValues[i] != v {
t.Errorf("AllowedValues[%d] = %q, want %q", i, f.AllowedValues[i], v)
}
}
}
func TestLoadCustomFields_BadTypeRejected(t *testing.T) {
cwdDir := setupLoadCustomFieldsTest(t)
content := `
fields:
- name: broken
type: nosuchtype
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
err := LoadCustomFields()
if err == nil {
t.Fatal("expected error for unknown type")
}
}
func TestLoadCustomFields_EnumWithoutValues(t *testing.T) {
cwdDir := setupLoadCustomFieldsTest(t)
content := `
fields:
- name: severity
type: enum
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
err := LoadCustomFields()
if err == nil {
t.Fatal("expected error for enum without values")
}
}
func TestLoadCustomFields_ConflictingRedefinition(t *testing.T) {
cwdDir := setupLoadCustomFieldsTest(t)
pm := mustGetPathManager()
// write field definition in project config
projectWorkflow := filepath.Join(pm.ProjectConfigDir(), "workflow.yaml")
content1 := `
fields:
- name: score
type: integer
`
if err := os.WriteFile(projectWorkflow, []byte(content1), 0644); err != nil {
t.Fatal(err)
}
// write conflicting definition in cwd
content2 := `
fields:
- name: score
type: text
`
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content2), 0644); err != nil {
t.Fatal(err)
}
err := LoadCustomFields()
if err == nil {
t.Fatal("expected error for conflicting redefinition")
}
}
func TestLoadCustomFields_IdenticalRedefinition(t *testing.T) {
cwdDir := setupLoadCustomFieldsTest(t)
pm := mustGetPathManager()
// write identical definitions in two locations
content := `
fields:
- name: score
type: integer
`
projectWorkflow := filepath.Join(pm.ProjectConfigDir(), "workflow.yaml")
if err := os.WriteFile(projectWorkflow, []byte(content), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
if err := LoadCustomFields(); err != nil {
t.Fatalf("identical redefinition should succeed: %v", err)
}
f, ok := workflow.Field("score")
if !ok {
t.Fatal("score field not found")
}
if f.Type != workflow.TypeInt {
t.Errorf("score.Type = %v, want TypeInt", f.Type)
}
}

View file

@ -203,6 +203,7 @@ type workflowFileData struct {
Statuses []map[string]interface{} `yaml:"statuses,omitempty"`
Views viewsFileData `yaml:"views,omitempty"`
Triggers []map[string]interface{} `yaml:"triggers,omitempty"`
Fields []map[string]interface{} `yaml:"fields,omitempty"`
}
// readWorkflowFile reads and unmarshals workflow.yaml from the given path.

View file

@ -29,11 +29,13 @@ var (
)
// LoadStatusRegistry reads the statuses: section from workflow.yaml files.
// The last file from FindWorkflowFiles() that contains a non-empty statuses list wins
// Uses FindRegistryWorkflowFiles (no views filtering) so files with empty views:
// still contribute status definitions.
// The last file that contains a non-empty statuses list wins
// (most specific location takes precedence, matching plugin merge behavior).
// Returns an error if no statuses are defined anywhere (no Go fallback).
func LoadStatusRegistry() error {
files := FindWorkflowFiles()
files := FindRegistryWorkflowFiles()
if len(files) == 0 {
return fmt.Errorf("no workflow.yaml found; statuses must be defined in workflow.yaml")
}
@ -116,6 +118,7 @@ func MaybeGetTypeRegistry() (*workflow.TypeRegistry, bool) {
}
// ResetStatusRegistry replaces the global registry with one built from the given defs.
// Also clears custom fields so test helpers don't leak registry state.
// Intended for tests only.
func ResetStatusRegistry(defs []workflow.StatusDef) {
reg, err := workflow.NewStatusRegistry(defs)
@ -130,14 +133,19 @@ func ResetStatusRegistry(defs []workflow.StatusDef) {
globalStatusRegistry = reg
globalTypeRegistry = typeReg
registryMu.Unlock()
workflow.ClearCustomFields()
registriesLoaded.Store(true)
}
// ClearStatusRegistry removes the global registries. Intended for test teardown.
// ClearStatusRegistry removes the global registries and clears custom fields.
// Intended for test teardown.
func ClearStatusRegistry() {
registryMu.Lock()
globalStatusRegistry = nil
globalTypeRegistry = nil
registryMu.Unlock()
workflow.ClearCustomFields()
registriesLoaded.Store(false)
}
// --- internal ---

View file

@ -77,9 +77,9 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
slog.Warn("failed to install default workflow", "error", err)
}
// Phase 2.7: Load status definitions from workflow.yaml
if err := config.LoadStatusRegistry(); err != nil {
return nil, fmt.Errorf("load status registry: %w", err)
// Phase 2.7: Load workflow registries (statuses, types, custom fields)
if err := config.LoadWorkflowRegistries(); err != nil {
return nil, fmt.Errorf("load workflow registries: %w", err)
}
// Phase 3: Configuration and logging

View file

@ -82,9 +82,9 @@ func CreateTaskFromReader(r io.Reader) (string, error) {
return "", fmt.Errorf("project not initialized: run 'tiki init' first")
}
// Load status definitions before creating tasks
if err := config.LoadStatusRegistry(); err != nil {
return "", fmt.Errorf("load status registry: %w", err)
// load workflow registries (statuses, types, custom fields) before creating tasks
if err := config.LoadWorkflowRegistries(); err != nil {
return "", fmt.Errorf("load workflow registries: %w", err)
}
gate := service.BuildGate()

View file

@ -134,6 +134,17 @@ func extractFieldValue(t *task.Task, name string) interface{} {
case "updatedAt":
return t.UpdatedAt
default:
fd, ok := workflow.Field(name)
if !ok || !fd.Custom {
return nil
}
if t.CustomFields != nil {
if v, exists := t.CustomFields[name]; exists {
return v
}
}
// unset custom field — return nil to match executor semantics;
// renderValue converts nil to "" so unset fields display as blank
return nil
}
}
@ -153,6 +164,10 @@ func renderValue(val interface{}, vt workflow.ValueType) string {
return renderList(val)
case workflow.TypeInt:
return renderInt(val)
case workflow.TypeEnum:
return escapeScalar(fmt.Sprint(val))
case workflow.TypeBool:
return fmt.Sprint(val)
default:
return escapeScalar(fmt.Sprint(val))
}

View file

@ -9,6 +9,7 @@ import (
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/task"
"github.com/boolean-maybe/tiki/workflow"
)
func TestTableFormatterProjectedFields(t *testing.T) {
@ -556,3 +557,139 @@ func TestEscapeScalar(t *testing.T) {
}
}
}
func TestFormatCustomFields(t *testing.T) {
// register custom fields so extractFieldValue and resolveFields find them
initTestRegistries()
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "severity", Type: workflow.TypeEnum, AllowedValues: []string{"low", "medium", "high"}},
{Name: "score", Type: workflow.TypeInt},
{Name: "active", Type: workflow.TypeBool},
{Name: "notes", Type: workflow.TypeString},
}); err != nil {
t.Fatalf("register custom fields: %v", err)
}
t.Cleanup(func() { workflow.ClearCustomFields() })
proj := &ruki.TaskProjection{
Fields: []string{"severity", "score", "active", "notes"},
Tasks: []*task.Task{
{
ID: "TIKI-CF0001", Title: "Custom", Status: "ready",
CustomFields: map[string]interface{}{
"severity": "high",
"score": 42,
"active": true,
"notes": "important",
},
},
},
}
var buf bytes.Buffer
if err := NewTableFormatter().Format(&buf, proj); err != nil {
t.Fatal(err)
}
out := buf.String()
if !strings.Contains(out, "high") {
t.Errorf("missing severity value:\n%s", out)
}
if !strings.Contains(out, "42") {
t.Errorf("missing score value:\n%s", out)
}
if !strings.Contains(out, "true") {
t.Errorf("missing active value:\n%s", out)
}
if !strings.Contains(out, "important") {
t.Errorf("missing notes value:\n%s", out)
}
}
func TestFormatMissingCustomFields(t *testing.T) {
initTestRegistries()
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "severity", Type: workflow.TypeEnum, AllowedValues: []string{"low", "medium", "high"}},
{Name: "score", Type: workflow.TypeInt},
{Name: "active", Type: workflow.TypeBool},
{Name: "notes", Type: workflow.TypeString},
}); err != nil {
t.Fatalf("register custom fields: %v", err)
}
t.Cleanup(func() { workflow.ClearCustomFields() })
// task with no custom fields set — should render as empty (nil → "")
proj := &ruki.TaskProjection{
Fields: []string{"score", "active"},
Tasks: []*task.Task{
{ID: "TIKI-CF0002", Title: "Empty", Status: "ready"},
},
}
var buf bytes.Buffer
if err := NewTableFormatter().Format(&buf, proj); err != nil {
t.Fatal(err)
}
out := buf.String()
lines := strings.Split(out, "\n")
// data row is lines[3]
dataRow := lines[3]
parts := strings.Split(dataRow, "|")
// score (int, unset) → ""
scoreCell := strings.TrimSpace(parts[1])
if scoreCell != "" {
t.Errorf("unset int custom field should render as empty, got %q", scoreCell)
}
// active (bool, unset) → ""
activeCell := strings.TrimSpace(parts[2])
if activeCell != "" {
t.Errorf("unset bool custom field should render as empty, got %q", activeCell)
}
}
func TestFormatSetToZeroVsUnset(t *testing.T) {
initTestRegistries()
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "score", Type: workflow.TypeInt},
{Name: "active", Type: workflow.TypeBool},
}); err != nil {
t.Fatalf("register custom fields: %v", err)
}
t.Cleanup(func() { workflow.ClearCustomFields() })
proj := &ruki.TaskProjection{
Fields: []string{"score", "active"},
Tasks: []*task.Task{
{ID: "TIKI-Z00001", Title: "Explicit zero", Status: "ready",
CustomFields: map[string]interface{}{"score": 0, "active": false}},
{ID: "TIKI-Z00002", Title: "Unset", Status: "ready"},
},
}
var buf bytes.Buffer
if err := NewTableFormatter().Format(&buf, proj); err != nil {
t.Fatal(err)
}
out := buf.String()
lines := strings.Split(out, "\n")
// first data row (explicit zero): score=0, active=false
row1 := strings.Split(lines[3], "|")
if s := strings.TrimSpace(row1[1]); s != "0" {
t.Errorf("explicit zero int should render as '0', got %q", s)
}
if s := strings.TrimSpace(row1[2]); s != "false" {
t.Errorf("explicit false bool should render as 'false', got %q", s)
}
// second data row (unset): both empty
row2 := strings.Split(lines[4], "|")
if s := strings.TrimSpace(row2[1]); s != "" {
t.Errorf("unset int should render as empty, got %q", s)
}
if s := strings.TrimSpace(row2[2]); s != "" {
t.Errorf("unset bool should render as empty, got %q", s)
}
}

View file

@ -6,32 +6,54 @@ import (
"github.com/boolean-maybe/tiki/workflow"
)
// workflowSchema adapts workflow.Fields(), config.GetStatusRegistry(), and
// config.GetTypeRegistry() into the ruki.Schema interface used by the parser
// and executor.
// workflowSchema adapts a snapshot of workflow.Fields(), config.GetStatusRegistry(),
// and config.GetTypeRegistry() into the ruki.Schema interface used by the parser
// and executor. The field catalog is snapshotted at construction time so an old
// schema never observes newly loaded custom fields through live global lookups.
type workflowSchema struct {
statusReg *workflow.StatusRegistry
typeReg *workflow.TypeRegistry
statusReg *workflow.StatusRegistry
typeReg *workflow.TypeRegistry
fieldsByName map[string]ruki.FieldSpec // snapshotted at construction
}
// NewSchema constructs a ruki.Schema backed by the loaded workflow registries.
// Must be called after config.LoadStatusRegistry().
// Snapshots the current field catalog (built-in + custom) so the schema is
// immutable after creation. Must be called after config.LoadStatusRegistry()
// (and config.LoadCustomFields() if custom fields are in use).
func NewSchema() ruki.Schema {
fields := workflow.Fields() // includes custom fields
byName := make(map[string]ruki.FieldSpec, len(fields))
for _, fd := range fields {
spec := ruki.FieldSpec{
Name: fd.Name,
Type: mapValueType(fd.Type),
Custom: fd.Custom,
}
if fd.AllowedValues != nil {
spec.AllowedValues = make([]string, len(fd.AllowedValues))
copy(spec.AllowedValues, fd.AllowedValues)
}
byName[fd.Name] = spec
}
return &workflowSchema{
statusReg: config.GetStatusRegistry(),
typeReg: config.GetTypeRegistry(),
statusReg: config.GetStatusRegistry(),
typeReg: config.GetTypeRegistry(),
fieldsByName: byName,
}
}
func (s *workflowSchema) Field(name string) (ruki.FieldSpec, bool) {
fd, ok := workflow.Field(name)
spec, ok := s.fieldsByName[name]
if !ok {
return ruki.FieldSpec{}, false
}
return ruki.FieldSpec{
Name: fd.Name,
Type: mapValueType(fd.Type),
}, true
// return a defensive copy so callers cannot mutate schema state
out := spec
if spec.AllowedValues != nil {
out.AllowedValues = make([]string, len(spec.AllowedValues))
copy(out.AllowedValues, spec.AllowedValues)
}
return out, true
}
func (s *workflowSchema) NormalizeStatus(raw string) (string, bool) {
@ -77,6 +99,8 @@ func mapValueType(wt workflow.ValueType) ruki.ValueType {
return ruki.ValueStatus
case workflow.TypeTaskType:
return ruki.ValueTaskType
case workflow.TypeEnum:
return ruki.ValueEnum
default:
return ruki.ValueString
}

View file

@ -216,8 +216,8 @@ func runExec(args []string) int {
_, _ = fmt.Fprintf(os.Stderr, "warning: install default workflow: %v\n", err)
}
if err := config.LoadStatusRegistry(); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "error: load status registry: %v\n", err)
if err := config.LoadWorkflowRegistries(); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "error: load workflow registries: %v\n", err)
return exitStartupFailure
}

View file

@ -12,28 +12,58 @@ import (
)
// fieldNameMap maps lowercase field names to their canonical form.
// Built once from workflow.Fields() + the "tag"→"tags" alias.
// Rebuilt from workflow.Fields() on demand; invalidated when custom fields change.
var (
fieldNameMap map[string]string
fieldNameOnce sync.Once
fieldNameMap map[string]string
fieldNameMu sync.RWMutex
)
func init() {
workflow.OnCustomFieldsChanged(InvalidateFieldNameCache)
}
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
})
fieldNameMu.RLock()
if fieldNameMap != nil {
fieldNameMu.RUnlock()
return
}
fieldNameMu.RUnlock()
fieldNameMu.Lock()
defer fieldNameMu.Unlock()
if fieldNameMap != nil {
return // double-check
}
rebuildFieldNameMapLocked()
}
func rebuildFieldNameMapLocked() {
fields := workflow.Fields()
m := make(map[string]string, len(fields)+1)
for _, f := range fields {
m[strings.ToLower(f.Name)] = f.Name
}
m["tag"] = "tags" // singular alias
fieldNameMap = m
}
// InvalidateFieldNameCache clears the cached field-name lookup so the next
// legacy conversion picks up newly registered custom fields.
func InvalidateFieldNameCache() {
fieldNameMu.Lock()
fieldNameMap = nil
fieldNameMu.Unlock()
}
// 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 {
fieldNameMu.RLock()
canonical, ok := fieldNameMap[strings.ToLower(name)]
fieldNameMu.RUnlock()
if ok {
return canonical
}
return name
@ -281,7 +311,7 @@ func expandInClause(fieldName, valuesStr string) string {
}
// scalar field: just lowercase the IN
canonical := normalizeFieldName(fieldName)
return canonical + " in [" + normalizeQuotedValues(valuesStr) + "]"
return canonical + " in [" + normalizeQuotedValues(valuesStr, canonical) + "]"
}
// expandNotInClause handles field NOT IN [...] expansion.
@ -301,11 +331,11 @@ func expandNotInClause(fieldName, valuesStr string) string {
return "(" + strings.Join(parts, " and ") + ")"
}
canonical := normalizeFieldName(fieldName)
return canonical + " not in [" + normalizeQuotedValues(valuesStr) + "]"
return canonical + " not in [" + normalizeQuotedValues(valuesStr, canonical) + "]"
}
// parseBracketValues parses a comma-separated list of values from inside [...].
// Ensures all values are double-quoted.
// All values are double-quoted as string literals.
func parseBracketValues(s string) []string {
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
@ -314,15 +344,16 @@ func parseBracketValues(s string) []string {
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 {
// normalizeQuotedValues ensures all values in a comma-separated list use
// type-aware quoting based on the target field.
func normalizeQuotedValues(s string, fieldName string) string {
ft := lookupFieldType(fieldName)
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
@ -331,7 +362,7 @@ func normalizeQuotedValues(s string) string {
continue
}
p = strings.Trim(p, `"'`)
result = append(result, `"`+p+`"`)
result = append(result, quoteListElementForField(p, ft))
}
return strings.Join(result, ", ")
}
@ -455,13 +486,15 @@ func convertActionSegment(seg string) (string, error) {
if idx := strings.Index(seg, "+="); idx > 0 {
fieldName := normalizeFieldName(strings.TrimSpace(seg[:idx]))
value := strings.TrimSpace(seg[idx+2:])
converted := convertBracketValues(value)
ft := lookupFieldType(fieldName)
converted := convertBracketValuesTyped(value, ft)
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)
ft := lookupFieldType(fieldName)
converted := convertBracketValuesTyped(value, ft)
return fieldName + "=" + fieldName + "-" + converted, nil
}
@ -481,8 +514,9 @@ func convertActionSegment(seg string) (string, error) {
value = reSingleQuoted.ReplaceAllString(value, `"$1"`)
// convert CURRENT_USER
value = reCurrentUser.ReplaceAllString(value, "user()")
// quote bare identifiers
value = quoteIfBareIdentifier(value)
// quote with type awareness
ft := lookupFieldType(fieldName)
value = quoteValueForField(value, ft)
return fieldName + "=" + value, nil
}
@ -505,14 +539,24 @@ func convertBracketValues(s string) string {
if p == "" {
continue
}
// strip existing quotes
p = strings.Trim(p, `"'`)
// re-quote — all values in action brackets must be strings
converted = append(converted, `"`+p+`"`)
converted = append(converted, quoteListElement(p))
}
return "[" + strings.Join(converted, ", ") + "]"
}
// quoteListElement quotes a list element value for ruki syntax.
// Bool literals and numerics stay bare; everything else gets double-quoted.
func quoteListElement(value string) string {
if strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
return strings.ToLower(value)
}
if reNumeric.MatchString(value) {
return value
}
return `"` + value + `"`
}
var (
// matches function calls like now(), user(), id()
reFunctionCall = regexp.MustCompile(`^\w+\(\)$`)
@ -524,6 +568,7 @@ var (
// quoteIfBareIdentifier wraps a value in double quotes if it's a bare identifier
// (not numeric, not a function call, not already quoted).
// Bool literals (true/false) are left bare so the parser produces BoolLiteral nodes.
func quoteIfBareIdentifier(value string) string {
if value == "" {
return value
@ -537,12 +582,111 @@ func quoteIfBareIdentifier(value string) string {
if reFunctionCall.MatchString(value) {
return value // function call
}
if strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
return strings.ToLower(value) // bool literal — keep bare
}
if reBareIdentifier.MatchString(value) {
return `"` + value + `"`
}
return value
}
// lookupFieldType returns a pointer to the field's ValueType, or nil if unknown.
func lookupFieldType(fieldName string) *workflow.ValueType {
fd, ok := workflow.Field(fieldName)
if !ok {
return nil
}
t := fd.Type
return &t
}
// quoteValueForField quotes a value according to the target field's type.
// Only leaves bools bare for TypeBool, numbers bare for TypeInt.
// Unknown field type defaults to quoting (safe fallback).
func quoteValueForField(value string, ft *workflow.ValueType) string {
if value == "" {
return value
}
if strings.HasPrefix(value, `"`) {
return value
}
if reFunctionCall.MatchString(value) {
return value
}
if ft != nil {
switch *ft {
case workflow.TypeBool:
if strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
return strings.ToLower(value)
}
case workflow.TypeInt:
if reNumeric.MatchString(value) {
return value
}
}
// all other field types: quote bare identifiers, boolish and numeric strings
if reBareIdentifier.MatchString(value) || reNumeric.MatchString(value) ||
strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
return `"` + value + `"`
}
return value
}
// unknown field type: quote bare identifiers (safe fallback)
if reBareIdentifier.MatchString(value) {
return `"` + value + `"`
}
return value
}
// quoteListElementForField quotes a list element according to the target field's type.
func quoteListElementForField(value string, ft *workflow.ValueType) string {
if ft != nil {
switch *ft {
case workflow.TypeBool:
if strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
return strings.ToLower(value)
}
case workflow.TypeInt:
if reNumeric.MatchString(value) {
return value
}
}
return `"` + value + `"`
}
// unknown field type: always quote (safe fallback)
return `"` + value + `"`
}
// convertBracketValuesTyped converts a bracket-enclosed list with type-aware quoting.
func convertBracketValuesTyped(s string, ft *workflow.ValueType) string {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") {
s = reSingleQuoted.ReplaceAllString(s, `"$1"`)
return quoteValueForField(s, ft)
}
// for list fields, elements are always strings
elemType := ft
if ft != nil && (*ft == workflow.TypeListString || *ft == workflow.TypeListRef) {
st := workflow.TypeString
elemType = &st
}
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
}
p = strings.Trim(p, `"'`)
converted = append(converted, quoteListElementForField(p, elemType))
}
return "[" + strings.Join(converted, ", ") + "]"
}
// splitTopLevelCommas splits a string on commas, respecting [...] brackets and quotes.
func splitTopLevelCommas(input string) ([]string, error) {
var result []string

View file

@ -4,8 +4,10 @@ import (
"strings"
"testing"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/task"
"github.com/boolean-maybe/tiki/workflow"
"gopkg.in/yaml.v3"
)
@ -1017,3 +1019,151 @@ func TestConvertViewsFormat_NoViewsKey(t *testing.T) {
t.Fatal("should not create views key when it doesn't exist")
}
}
func TestLegacyConvert_BoolLiteralBare(t *testing.T) {
config.MarkRegistriesLoadedForTest()
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "flag", Type: workflow.TypeBool, Custom: true},
}); err != nil {
t.Fatalf("register: %v", err)
}
t.Cleanup(func() { workflow.ClearCustomFields() })
tr := NewLegacyConfigTransformer()
// true/false values are emitted bare for TypeBool fields
got, err := tr.ConvertAction("flag=true")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := `update where id = id() set flag=true`
if got != want {
t.Errorf("ConvertAction(flag=true)\n got: %q\n want: %q", got, want)
}
got, err = tr.ConvertAction("flag=false")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want = `update where id = id() set flag=false`
if got != want {
t.Errorf("ConvertAction(flag=false)\n got: %q\n want: %q", got, want)
}
}
func TestLegacyConvert_BoolInList(t *testing.T) {
config.MarkRegistriesLoadedForTest()
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "flag", Type: workflow.TypeBool, Custom: true},
}); err != nil {
t.Fatalf("register: %v", err)
}
t.Cleanup(func() { workflow.ClearCustomFields() })
tr := NewLegacyConfigTransformer()
// bool values in lists are emitted bare for TypeBool fields
got := tr.ConvertFilter("flag IN [true, false]")
want := `select where flag in [true, false]`
if got != want {
t.Errorf("ConvertFilter(flag IN [true, false])\n got: %q\n want: %q", got, want)
}
}
func TestLegacyConvert_TypeAwareQuoting(t *testing.T) {
config.MarkRegistriesLoadedForTest()
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "severity", Type: workflow.TypeEnum, Custom: true, AllowedValues: []string{"low", "high", "true"}},
{Name: "notes", Type: workflow.TypeString, Custom: true},
{Name: "active", Type: workflow.TypeBool, Custom: true},
{Name: "score", Type: workflow.TypeInt, Custom: true},
}); err != nil {
t.Fatalf("register: %v", err)
}
t.Cleanup(func() { workflow.ClearCustomFields() })
tr := NewLegacyConfigTransformer()
tests := []struct {
name string
input string
want string
}{
{
name: "enum field with bool-like value is quoted",
input: "severity=true",
want: `update where id = id() set severity="true"`,
},
{
name: "string field with numeric value is quoted",
input: "notes=42",
want: `update where id = id() set notes="42"`,
},
{
name: "bool field with true stays bare",
input: "active=true",
want: `update where id = id() set active=true`,
},
{
name: "int field with number stays bare",
input: "score=42",
want: `update where id = id() set score=42`,
},
{
name: "string field with bool-like value is quoted",
input: "notes=false",
want: `update where id = id() set notes="false"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tr.ConvertAction(tt.input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("ConvertAction(%q)\n got: %q\n want: %q", tt.input, got, tt.want)
}
})
}
}
func TestLegacyConvert_TypeAwareListQuoting(t *testing.T) {
config.MarkRegistriesLoadedForTest()
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "labels", Type: workflow.TypeListString, Custom: true},
}); err != nil {
t.Fatalf("register: %v", err)
}
t.Cleanup(func() { workflow.ClearCustomFields() })
tr := NewLegacyConfigTransformer()
// list<string> field: all elements must be quoted, even bool-like and numeric
got, err := tr.ConvertAction("labels+=[true, 42, hello]")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := `update where id = id() set labels=labels+["true", "42", "hello"]`
if got != want {
t.Errorf("ConvertAction(labels+=[true, 42, hello])\n got: %q\n want: %q", got, want)
}
}
func TestLegacyConvert_UnknownFieldDefaultsToQuoting(t *testing.T) {
config.MarkRegistriesLoadedForTest()
t.Cleanup(func() { workflow.ClearCustomFields() })
tr := NewLegacyConfigTransformer()
// unknown field: "true" should be quoted as safe fallback
got, err := tr.ConvertAction("unknown_field=true")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := `update where id = id() set unknown_field="true"`
if got != want {
t.Errorf("ConvertAction(unknown_field=true)\n got: %q\n want: %q", got, want)
}
}

View file

@ -192,6 +192,11 @@ type BinaryExpr struct {
Right Expr
}
// BoolLiteral represents a bare true/false identifier lowered from FieldRef.
type BoolLiteral struct {
Value bool
}
// SubQuery represents "select [where <condition>]" used inside count().
type SubQuery struct {
Where Condition // nil = select all
@ -207,6 +212,7 @@ func (*ListLiteral) exprNode() {}
func (*EmptyLiteral) exprNode() {}
func (*FunctionCall) exprNode() {}
func (*BinaryExpr) exprNode() {}
func (*BoolLiteral) exprNode() {}
func (*SubQuery) exprNode() {}
// --- order by ---

View file

@ -185,23 +185,23 @@ func (e *Executor) buildPipeResult(pipe *RunAction, fields []string, matched []*
return nil, fmt.Errorf("pipe command must evaluate to string, got %T", cmdVal)
}
rows := buildFieldRows(fields, matched)
rows := e.buildFieldRows(fields, matched)
return &Result{Pipe: &PipeResult{Command: cmdStr, Rows: rows}}, nil
}
func (e *Executor) buildClipboardResult(fields []string, matched []*task.Task) (*Result, error) {
rows := buildFieldRows(fields, matched)
rows := e.buildFieldRows(fields, matched)
return &Result{Clipboard: &ClipboardResult{Rows: rows}}, nil
}
// buildFieldRows extracts the requested fields from matched tasks as string rows.
// Shared by both run() and clipboard() pipe targets.
func buildFieldRows(fields []string, matched []*task.Task) [][]string {
func (e *Executor) buildFieldRows(fields []string, matched []*task.Task) [][]string {
rows := make([][]string, len(matched))
for i, t := range matched {
row := make([]string, len(fields))
for j, f := range fields {
row[j] = pipeArgString(extractField(t, f))
row[j] = pipeArgString(e.extractField(t, f))
}
rows[i] = row
}
@ -384,7 +384,7 @@ func (e *Executor) setField(t *task.Task, name string, val interface{}) error {
t.DependsOn = nil
return nil
}
t.DependsOn = toStringSlice(val)
t.DependsOn = normalizeRefList(toStringSlice(val))
case "due":
if val == nil {
@ -423,7 +423,22 @@ func (e *Executor) setField(t *task.Task, name string, val interface{}) error {
t.Assignee = s
default:
return fmt.Errorf("unknown field %q", name)
fs, ok := e.schema.Field(name)
if !ok || !fs.Custom {
return fmt.Errorf("unknown field %q", name)
}
if val == nil {
delete(t.CustomFields, name)
return nil
}
coerced, err := coerceCustomFieldValue(fs, val)
if err != nil {
return fmt.Errorf("field %q: %w", name, err)
}
if t.CustomFields == nil {
t.CustomFields = make(map[string]interface{})
}
t.CustomFields[name] = coerced
}
return nil
}
@ -440,6 +455,73 @@ func toStringSlice(val interface{}) []string {
return result
}
func coerceCustomFieldValue(fs FieldSpec, val interface{}) (interface{}, error) {
switch fs.Type {
case ValueString:
s, ok := val.(string)
if !ok {
return nil, fmt.Errorf("expected string, got %T", val)
}
return s, nil
case ValueInt:
n, ok := val.(int)
if !ok {
return nil, fmt.Errorf("expected int, got %T", val)
}
return n, nil
case ValueBool:
if b, ok := val.(bool); ok {
return b, nil
}
if s, ok := val.(string); ok {
if b, err := parseBoolString(s); err == nil {
return b, nil
}
}
return nil, fmt.Errorf("expected bool, got %T", val)
case ValueTimestamp:
tv, ok := val.(time.Time)
if !ok {
return nil, fmt.Errorf("expected time.Time, got %T", val)
}
return tv, nil
case ValueEnum:
s, ok := val.(string)
if !ok {
return nil, fmt.Errorf("expected string, got %T", val)
}
for _, av := range fs.AllowedValues {
if strings.EqualFold(av, s) {
return av, nil
}
}
return nil, fmt.Errorf("invalid enum value %q", s)
case ValueListString:
return toStringSlice(val), nil
case ValueListRef:
raw := toStringSlice(val)
return normalizeRefList(raw), nil
default:
return nil, fmt.Errorf("unsupported custom field type")
}
}
// normalizeRefList trims whitespace and uppercases task ID references.
func normalizeRefList(ss []string) []string {
var result []string
for _, s := range ss {
s = strings.TrimSpace(s)
if s == "" {
continue
}
result = append(result, strings.ToUpper(s))
}
if result == nil {
result = []string{}
}
return result
}
// --- filtering ---
func (e *Executor) filterTasks(where Condition, tasks []*task.Task) ([]*task.Task, error) {
@ -543,10 +625,17 @@ func (e *Executor) evalIn(c *InExpr, t *task.Task, allTasks []*task.Task) (bool,
// list membership mode
if list, ok := collVal.([]interface{}); ok {
// unset field (nil) is not a member of any list
if val == nil {
return c.Negated, nil
}
valStr := normalizeToString(val)
// use case-insensitive comparison for enum-like fields
foldCase := isEnumLikeField(e.exprFieldType(c.Value))
found := false
for _, elem := range list {
if normalizeToString(elem) == valStr {
elemStr := normalizeToString(elem)
if foldCase && strings.EqualFold(valStr, elemStr) || !foldCase && valStr == elemStr {
found = true
break
}
@ -631,13 +720,15 @@ func (e *Executor) evalQuantifier(q *QuantifierExpr, t *task.Task, allTasks []*t
func (e *Executor) evalExpr(expr Expr, t *task.Task, allTasks []*task.Task) (interface{}, error) {
switch expr := expr.(type) {
case *FieldRef:
return extractField(t, expr.Name), nil
return e.extractField(t, expr.Name), nil
case *QualifiedRef:
return nil, fmt.Errorf("qualified references (old./new.) are not supported in standalone SELECT")
case *StringLiteral:
return expr.Value, nil
case *IntLiteral:
return expr.Value, nil
case *BoolLiteral:
return expr.Value, nil
case *DateLiteral:
return expr.Value, nil
case *DurationLiteral:
@ -854,8 +945,8 @@ func subtractValues(left, right interface{}) (interface{}, error) {
func (e *Executor) sortTasks(tasks []*task.Task, clauses []OrderByClause) {
sort.SliceStable(tasks, func(i, j int) bool {
for _, c := range clauses {
vi := extractField(tasks[i], c.Field)
vj := extractField(tasks[j], c.Field)
vi := e.extractField(tasks[i], c.Field)
vj := e.extractField(tasks[j], c.Field)
cmp := compareForSort(vi, vj)
if cmp == 0 {
continue
@ -887,6 +978,15 @@ func compareForSort(a, b interface{}) int {
case string:
bv, _ := b.(string)
return strings.Compare(av, bv)
case bool:
bv, _ := b.(bool)
if av == bv {
return 0
}
if !av && bv {
return -1 // false < true
}
return 1
case task.Status:
bv, _ := b.(task.Status)
return strings.Compare(string(av), string(bv))
@ -933,7 +1033,7 @@ func compareInts(a, b int) int {
func (e *Executor) compareValues(left, right interface{}, op string, leftExpr, rightExpr Expr) (bool, error) {
if left == nil || right == nil {
return compareWithNil(left, right, op)
return compareWithNil(left, right, op, leftExpr, rightExpr)
}
if leftList, ok := left.([]interface{}); ok {
@ -946,6 +1046,20 @@ func (e *Executor) compareValues(left, right interface{}, op string, leftExpr, r
if rb, ok := right.(bool); ok {
return compareBools(lb, rb, op)
}
// resilience: coerce string-encoded bool on right side
if rs, ok := right.(string); ok {
if rb, err := parseBoolString(rs); err == nil {
return compareBools(lb, rb, op)
}
}
}
if rb, ok := right.(bool); ok {
// resilience: coerce string-encoded bool on left side
if ls, ok := left.(string); ok {
if lb, err := parseBoolString(ls); err == nil {
return compareBools(lb, rb, op)
}
}
}
compType := e.resolveComparisonType(leftExpr, rightExpr)
@ -961,6 +1075,10 @@ func (e *Executor) compareValues(left, right interface{}, op string, leftExpr, r
ls := e.normalizeTypeStr(normalizeToString(left))
rs := e.normalizeTypeStr(normalizeToString(right))
return compareStrings(ls, rs, op)
case ValueEnum:
ls := strings.ToLower(normalizeToString(left))
rs := strings.ToLower(normalizeToString(right))
return compareStrings(ls, rs, op)
}
switch lv := left.(type) {
@ -998,10 +1116,10 @@ func (e *Executor) compareValues(left, right interface{}, op string, leftExpr, r
// resolveComparisonType returns the dominant field type for a comparison,
// checking both sides for enum/id fields that need special handling.
func (e *Executor) resolveComparisonType(left, right Expr) ValueType {
if t := e.exprFieldType(left); t == ValueID || t == ValueStatus || t == ValueTaskType {
if t := e.exprFieldType(left); t == ValueID || t == ValueStatus || t == ValueTaskType || t == ValueEnum {
return t
}
if t := e.exprFieldType(right); t == ValueID || t == ValueStatus || t == ValueTaskType {
if t := e.exprFieldType(right); t == ValueID || t == ValueStatus || t == ValueTaskType || t == ValueEnum {
return t
}
return -1
@ -1029,6 +1147,13 @@ func (e *Executor) exprFieldType(expr Expr) ValueType {
return fs.Type
}
// isEnumLikeField returns true for field types that use case-insensitive
// comparison in equality checks and should also use it for in/not-in.
// Includes ValueBool so that "True"/"true"/"TRUE" all match in bool in-lists.
func isEnumLikeField(t ValueType) bool {
return t == ValueEnum || t == ValueStatus || t == ValueTaskType || t == ValueID || t == ValueBool
}
func (e *Executor) normalizeStatusStr(s string) string {
if norm, ok := e.schema.NormalizeStatus(s); ok {
return norm
@ -1043,16 +1168,32 @@ func (e *Executor) normalizeTypeStr(s string) string {
return s
}
func compareWithNil(left, right interface{}, op string) (bool, error) {
// treat nil as empty; treat zero-valued non-nil as also matching empty
leftEmpty := isZeroValue(left)
rightEmpty := isZeroValue(right)
bothEmpty := leftEmpty && rightEmpty
func compareWithNil(left, right interface{}, op string, leftExpr, rightExpr Expr) (bool, error) {
// when comparing against EmptyLiteral, use zero-value semantics:
// nil and typed zeros both count as "empty"
_, leftIsEmpty := leftExpr.(*EmptyLiteral)
_, rightIsEmpty := rightExpr.(*EmptyLiteral)
if leftIsEmpty || rightIsEmpty {
leftEmpty := isZeroValue(left)
rightEmpty := isZeroValue(right)
bothEmpty := leftEmpty && rightEmpty
switch op {
case "=":
return bothEmpty, nil
case "!=":
return !bothEmpty, nil
default:
return false, nil
}
}
// concrete comparison: nil (unset field) only equals nil
bothNil := left == nil && right == nil
switch op {
case "=":
return bothEmpty, nil
return bothNil, nil
case "!=":
return !bothEmpty, nil
return !bothNil, nil
default:
return false, nil
}
@ -1186,7 +1327,7 @@ func compareDurations(a, b time.Duration, op string) (bool, error) {
// --- field extraction ---
func extractField(t *task.Task, name string) interface{} {
func (e *Executor) extractField(t *task.Task, name string) interface{} {
switch name {
case "id":
return t.ID
@ -1219,12 +1360,44 @@ func extractField(t *task.Task, name string) interface{} {
case "updatedAt":
return t.UpdatedAt
default:
fs, ok := e.schema.Field(name)
if !ok || !fs.Custom {
return nil
}
if t.CustomFields != nil {
if v, exists := t.CustomFields[name]; exists {
if fs.Type == ValueListString || fs.Type == ValueListRef {
if ss, ok := v.([]string); ok {
return toInterfaceSlice(ss)
}
}
return v
}
}
// unset custom field: list types return empty list (consistent
// with built-in tags/dependsOn), scalars return nil
if fs.Type == ValueListString || fs.Type == ValueListRef {
return []interface{}{}
}
return nil
}
}
// --- helpers ---
// parseBoolString converts a string "true"/"false" (case-insensitive) to a bool.
// Returns an error for any other string.
func parseBoolString(s string) (bool, error) {
switch strings.ToLower(s) {
case "true":
return true, nil
case "false":
return false, nil
default:
return false, fmt.Errorf("not a bool string: %q", s)
}
}
func toInterfaceSlice(ss []string) []interface{} {
if ss == nil {
return []interface{}{}

View file

@ -2,6 +2,7 @@ package ruki
import (
"fmt"
"reflect"
"strings"
"testing"
"time"
@ -1464,6 +1465,7 @@ func TestExecuteSortByStatusTypeRecurrence(t *testing.T) {
// --- extractField additional branches ---
func TestExtractFieldAllFields(t *testing.T) {
e := newTestExecutor()
tk := &task.Task{
ID: "T1", Title: "hi", Description: "desc", Status: "ready",
Type: "bug", Priority: 1, Points: 3, Tags: []string{"a"},
@ -1478,12 +1480,12 @@ func TestExtractFieldAllFields(t *testing.T) {
"createdBy", "createdAt", "updatedAt",
}
for _, f := range fields {
v := extractField(tk, f)
v := e.extractField(tk, f)
if v == nil {
t.Errorf("extractField(%q) returned nil", f)
}
}
if v := extractField(tk, "nonexistent"); v != nil {
if v := e.extractField(tk, "nonexistent"); v != nil {
t.Errorf("extractField(nonexistent) should be nil, got %v", v)
}
}
@ -3805,3 +3807,603 @@ func TestExecuteUnsupportedStatementType(t *testing.T) {
t.Errorf("expected 'unsupported statement type' error, got: %v", err)
}
}
// --- custom field executor tests ---
func newCustomExecutor() *Executor {
return NewExecutor(customTestSchema{}, func() string { return "alice" }, ExecutorRuntime{Mode: ExecutorRuntimeCLI})
}
func newCustomParser2() *Parser {
return NewParser(customTestSchema{})
}
func TestExecutor_CustomFieldExtraction(t *testing.T) {
e := newCustomExecutor()
// task with no custom fields — extractField returns nil for unset fields
tk := &task.Task{ID: "T1", Title: "x", Status: "ready"}
if v := e.extractField(tk, "notes"); v != nil {
t.Errorf("notes unset: got %v, want nil", v)
}
if v := e.extractField(tk, "score"); v != nil {
t.Errorf("score unset: got %v, want nil", v)
}
if v := e.extractField(tk, "flag"); v != nil {
t.Errorf("flag unset: got %v, want nil", v)
}
if v := e.extractField(tk, "severity"); v != nil {
t.Errorf("severity unset: got %v, want nil", v)
}
// task with custom fields set — returns actual values
tk2 := &task.Task{
ID: "T2", Title: "y", Status: "ready",
CustomFields: map[string]interface{}{
"notes": "hello",
"score": 42,
"flag": true,
"severity": "high",
},
}
if v := e.extractField(tk2, "notes"); v != "hello" {
t.Errorf("notes: got %v, want hello", v)
}
if v := e.extractField(tk2, "score"); v != 42 {
t.Errorf("score: got %v, want 42", v)
}
if v := e.extractField(tk2, "flag"); v != true {
t.Errorf("flag: got %v, want true", v)
}
if v := e.extractField(tk2, "severity"); v != "high" {
t.Errorf("severity: got %v, want high", v)
}
}
func TestExecutor_CustomFieldSetAndGet(t *testing.T) {
e := newCustomExecutor()
tk := &task.Task{ID: "T1", Title: "x", Status: "ready"}
if err := e.setField(tk, "notes", "hello"); err != nil {
t.Fatalf("setField notes: %v", err)
}
if err := e.setField(tk, "score", 42); err != nil {
t.Fatalf("setField score: %v", err)
}
if err := e.setField(tk, "flag", true); err != nil {
t.Fatalf("setField flag: %v", err)
}
if err := e.setField(tk, "severity", "high"); err != nil {
t.Fatalf("setField severity: %v", err)
}
if v := e.extractField(tk, "notes"); v != "hello" {
t.Errorf("notes: got %v, want hello", v)
}
if v := e.extractField(tk, "score"); v != 42 {
t.Errorf("score: got %v, want 42", v)
}
if v := e.extractField(tk, "flag"); v != true {
t.Errorf("flag: got %v, want true", v)
}
if v := e.extractField(tk, "severity"); v != "high" {
t.Errorf("severity: got %v, want high", v)
}
}
func TestExecutor_UnsetCustomListFieldReturnsEmptyList(t *testing.T) {
e := newCustomExecutor()
// task with no custom fields at all
tk := &task.Task{ID: "T1", Title: "x", Status: "ready"}
// unset custom list fields should return []interface{}{}, not nil
labelsVal := e.extractField(tk, "labels")
if labelsVal == nil {
t.Fatal("unset labels should return empty list, got nil")
}
labels, ok := labelsVal.([]interface{})
if !ok {
t.Fatalf("labels type = %T, want []interface{}", labelsVal)
}
if len(labels) != 0 {
t.Errorf("labels len = %d, want 0", len(labels))
}
relVal := e.extractField(tk, "related")
if relVal == nil {
t.Fatal("unset related should return empty list, got nil")
}
related, ok := relVal.([]interface{})
if !ok {
t.Fatalf("related type = %T, want []interface{}", relVal)
}
if len(related) != 0 {
t.Errorf("related len = %d, want 0", len(related))
}
}
func TestExecutor_UnsetCustomListField_InExprWorks(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
// task with no "labels" set — in-expr should work without error
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready"},
}
stmt, err := p.ParseStatement(`select where "bug" in labels`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute should not error on unset custom list: %v", err)
}
if len(result.Select.Tasks) != 0 {
t.Errorf("expected 0 matching tasks, got %d", len(result.Select.Tasks))
}
}
func TestExecutor_UnsetCustomListField_AddWorks(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
// task with no "labels" set — update adding to list should work
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready"},
}
stmt, err := p.ParseStatement(`update where id = "T1" set labels = labels + ["new"]`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute should not error on unset custom list add: %v", err)
}
if len(result.Update.Updated) != 1 {
t.Fatalf("expected 1 updated task, got %d", len(result.Update.Updated))
}
}
func TestExecutor_CustomFieldNilDelete(t *testing.T) {
e := newCustomExecutor()
tk := &task.Task{
ID: "T1", Title: "x", Status: "ready",
CustomFields: map[string]interface{}{
"notes": "hello",
"score": 42,
},
}
if err := e.setField(tk, "notes", nil); err != nil {
t.Fatalf("setField nil: %v", err)
}
if _, exists := tk.CustomFields["notes"]; exists {
t.Error("notes should be deleted from CustomFields after nil set")
}
// extractField should return nil for deleted (unset) field
if v := e.extractField(tk, "notes"); v != nil {
t.Errorf("notes after delete: got %v, want nil", v)
}
}
func TestExecutor_SelectWhereCustomEnum(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"severity": "low"}},
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"severity": "high"}},
{ID: "T3", Title: "C", Status: "ready", CustomFields: map[string]interface{}{"severity": "low"}},
{ID: "T4", Title: "D", Status: "ready"}, // no severity set
}
stmt, err := p.ParseStatement(`select where severity = "low"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result.Select.Tasks) != 2 {
ids := make([]string, len(result.Select.Tasks))
for i, tk := range result.Select.Tasks {
ids[i] = tk.ID
}
t.Fatalf("expected 2 tasks, got %d: %v", len(result.Select.Tasks), ids)
}
if result.Select.Tasks[0].ID != "T1" || result.Select.Tasks[1].ID != "T3" {
t.Errorf("expected T1, T3; got %s, %s", result.Select.Tasks[0].ID, result.Select.Tasks[1].ID)
}
}
func TestExecutor_UpdateSetCustomField(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"severity": "low"}},
}
stmt, err := p.ParseStatement(`update where id = "T1" set severity="high"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if result.Update == nil || len(result.Update.Updated) != 1 {
t.Fatal("expected 1 updated task")
}
updated := result.Update.Updated[0]
if updated.CustomFields["severity"] != "high" {
t.Errorf("severity = %v, want high", updated.CustomFields["severity"])
}
}
func TestExecutor_OrderByCustomEnum(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"severity": "high"}},
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"severity": "critical"}},
{ID: "T3", Title: "C", Status: "ready", CustomFields: map[string]interface{}{"severity": "low"}},
}
stmt, err := p.ParseStatement(`select order by severity`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
// enum values are compared as strings: "critical" < "high" < "low"
wantIDs := []string{"T2", "T1", "T3"}
if len(result.Select.Tasks) != 3 {
t.Fatalf("expected 3 tasks, got %d", len(result.Select.Tasks))
}
for i, wantID := range wantIDs {
if result.Select.Tasks[i].ID != wantID {
t.Errorf("task[%d].ID = %q, want %q", i, result.Select.Tasks[i].ID, wantID)
}
}
}
func TestExecutor_OrderByCustomBool(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": false}},
{ID: "T3", Title: "C", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
}
stmt, err := p.ParseStatement(`select order by flag`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
// false < true: T2 first, then T1 and T3 (stable order)
if len(result.Select.Tasks) != 3 {
t.Fatalf("expected 3 tasks, got %d", len(result.Select.Tasks))
}
if result.Select.Tasks[0].ID != "T2" {
t.Errorf("task[0].ID = %q, want T2 (false before true)", result.Select.Tasks[0].ID)
}
if result.Select.Tasks[1].ID != "T1" {
t.Errorf("task[1].ID = %q, want T1", result.Select.Tasks[1].ID)
}
if result.Select.Tasks[2].ID != "T3" {
t.Errorf("task[2].ID = %q, want T3", result.Select.Tasks[2].ID)
}
}
func TestExecutor_BoolLiteralEval(t *testing.T) {
e := newCustomExecutor()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": false}},
}
// manually construct statement with BoolLiteral (as lowering produces)
stmt := &Statement{
Select: &SelectStmt{
Where: &CompareExpr{
Left: &FieldRef{Name: "flag"},
Op: "=",
Right: &BoolLiteral{Value: true},
},
},
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T1" {
t.Fatalf("expected T1, got %v", result.Select.Tasks)
}
// test false literal
stmt2 := &Statement{
Select: &SelectStmt{
Where: &CompareExpr{
Left: &FieldRef{Name: "flag"},
Op: "=",
Right: &BoolLiteral{Value: false},
},
},
}
result2, err := e.Execute(stmt2, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result2.Select.Tasks) != 1 || result2.Select.Tasks[0].ID != "T2" {
t.Fatalf("expected T2, got %v", result2.Select.Tasks)
}
}
func TestExecutor_BoolStringCoercion(t *testing.T) {
e := newCustomExecutor()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": false}},
}
// compare bool field against string "true" — should coerce and match
stmt := &Statement{
Select: &SelectStmt{
Where: &CompareExpr{
Left: &FieldRef{Name: "flag"},
Op: "=",
Right: &StringLiteral{Value: "true"},
},
},
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T1" {
t.Errorf("bool=string 'true': got %v, want [T1]", result.Select.Tasks)
}
// compare bool field against string "FALSE" — case-insensitive coercion
stmt2 := &Statement{
Select: &SelectStmt{
Where: &CompareExpr{
Left: &FieldRef{Name: "flag"},
Op: "=",
Right: &StringLiteral{Value: "FALSE"},
},
},
}
result2, err := e.Execute(stmt2, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result2.Select.Tasks) != 1 || result2.Select.Tasks[0].ID != "T2" {
t.Errorf("bool=string 'FALSE': got %v, want [T2]", result2.Select.Tasks)
}
}
func TestExecutor_BoolInCaseInsensitive(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": false}},
}
// bool field in list of string bool-literals with mixed case
stmt, err := p.ParseStatement(`select where flag in ["True", "FALSE"]`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result.Select.Tasks) != 2 {
t.Errorf("bool in [True, FALSE]: got %d tasks, want 2", len(result.Select.Tasks))
}
}
func TestExecutor_NilDoesNotMatchZero(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready"}, // flag unset, score unset
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": false, "score": 0}},
{ID: "T3", Title: "C", Status: "ready", CustomFields: map[string]interface{}{"flag": true, "score": 42}},
}
// "flag = false" should only match T2 (explicitly false), not T1 (unset)
stmt, err := p.ParseStatement(`select where flag = false`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T2" {
ids := make([]string, len(result.Select.Tasks))
for i, tk := range result.Select.Tasks {
ids[i] = tk.ID
}
t.Errorf("flag=false: got %v, want [T2]", ids)
}
// "score = 0" should only match T2, not T1
stmt2, err := p.ParseStatement(`select where score = 0`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result2, err := e.Execute(stmt2, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result2.Select.Tasks) != 1 || result2.Select.Tasks[0].ID != "T2" {
ids := make([]string, len(result2.Select.Tasks))
for i, tk := range result2.Select.Tasks {
ids[i] = tk.ID
}
t.Errorf("score=0: got %v, want [T2]", ids)
}
}
func TestExecutor_NilMatchesIsEmpty(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready"}, // flag unset
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
}
// "flag is empty" should match T1 (unset → nil → isZeroValue true)
stmt, err := p.ParseStatement(`select where flag is empty`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T1" {
ids := make([]string, len(result.Select.Tasks))
for i, tk := range result.Select.Tasks {
ids[i] = tk.ID
}
t.Errorf("flag is empty: got %v, want [T1]", ids)
}
}
func TestExecutor_NilSortsFirst(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"score": 10}},
{ID: "T2", Title: "B", Status: "ready"}, // score unset → nil
{ID: "T3", Title: "C", Status: "ready", CustomFields: map[string]interface{}{"score": 0}},
}
// "order by score" — nil sorts before 0 before 10
stmt, err := p.ParseStatement(`select order by score`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
gotIDs := make([]string, len(result.Select.Tasks))
for i, tk := range result.Select.Tasks {
gotIDs[i] = tk.ID
}
wantIDs := []string{"T2", "T3", "T1"} // nil, 0, 10
if !reflect.DeepEqual(gotIDs, wantIDs) {
t.Errorf("order by score: got %v, want %v", gotIDs, wantIDs)
}
}
func TestExecutor_NilNotInList(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "ready"}, // severity unset
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"severity": "low"}},
}
// unset field should not be "in" any list
stmt, err := p.ParseStatement(`select where severity in ["low"]`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T2" {
ids := make([]string, len(result.Select.Tasks))
for i, tk := range result.Select.Tasks {
ids[i] = tk.ID
}
t.Errorf("severity in [low]: got %v, want [T2]", ids)
}
}
func TestExecutor_EnumInCaseInsensitive(t *testing.T) {
e := newCustomExecutor()
p := newCustomParser2()
tasks := []*task.Task{
{ID: "T1", Title: "A", Status: "done", Type: "story", CustomFields: map[string]interface{}{"severity": "low"}},
{ID: "T2", Title: "B", Status: "ready", Type: "bug", CustomFields: map[string]interface{}{"severity": "high"}},
{ID: "T3", Title: "C", Status: "ready", Type: "story", CustomFields: map[string]interface{}{"severity": "critical"}},
}
tests := []struct {
name string
query string
wantIDs []string
}{
{
name: "custom enum in with different case",
query: `select where severity in ["LOW"]`,
wantIDs: []string{"T1"},
},
{
name: "custom enum not-in with different case",
query: `select where severity not in ["LOW", "HIGH"]`,
wantIDs: []string{"T3"},
},
{
name: "custom enum in with mixed case list",
query: `select where severity in ["High", "Critical"]`,
wantIDs: []string{"T2", "T3"},
},
{
name: "built-in status in canonical case",
query: `select where status in ["done"]`,
wantIDs: []string{"T1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stmt, err := p.ParseStatement(tt.query)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
gotIDs := make([]string, len(result.Select.Tasks))
for i, tk := range result.Select.Tasks {
gotIDs[i] = tk.ID
}
if !reflect.DeepEqual(gotIDs, tt.wantIDs) {
t.Errorf("got IDs %v, want %v", gotIDs, tt.wantIDs)
}
})
}
}

View file

@ -37,6 +37,10 @@ func List() []string {
return result
}
// IdentPattern is the anchored regex for valid ruki identifiers.
// Kept in sync with the Ident rule in the ruki lexer.
const IdentPattern = `^[a-zA-Z_][a-zA-Z0-9_]*$`
// Pattern returns the regex alternation for the lexer Keyword rule.
func Pattern() string {
return `\b(` + strings.Join(reserved[:], "|") + `)\b`

View file

@ -3,6 +3,7 @@ package ruki
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/boolean-maybe/tiki/util/duration"
@ -391,6 +392,13 @@ func lowerUnary(g *unaryExpr) (Expr, error) {
case g.Empty != nil:
return &EmptyLiteral{}, nil
case g.FieldRef != nil:
// intercept bare true/false identifiers as boolean literals
if strings.EqualFold(*g.FieldRef, "true") {
return &BoolLiteral{Value: true}, nil
}
if strings.EqualFold(*g.FieldRef, "false") {
return &BoolLiteral{Value: false}, nil
}
return &FieldRef{Name: *g.FieldRef}, nil
case g.Paren != nil:
return lowerExpr(g.Paren)

View file

@ -35,12 +35,15 @@ const (
ValueListRef // dependsOn
ValueStatus // status enum
ValueTaskType // type enum
ValueEnum // custom enum backed by FieldSpec.AllowedValues
)
// FieldSpec describes a single task field for the parser.
type FieldSpec struct {
Name string
Type ValueType
Name string
Type ValueType
Custom bool // true for user-defined fields
AllowedValues []string // non-nil only for ValueEnum fields
}
// Parser parses ruki DSL statements and triggers.

View file

@ -727,6 +727,8 @@ func cloneExpr(expr Expr) Expr {
return &StringLiteral{Value: e.Value}
case *IntLiteral:
return &IntLiteral{Value: e.Value}
case *BoolLiteral:
return &BoolLiteral{Value: e.Value}
case *DateLiteral:
return &DateLiteral{Value: e.Value}
case *DurationLiteral:

View file

@ -220,12 +220,12 @@ func (e *triggerExecOverride) resolveQualifiedRef(qr *QualifiedRef) (interface{}
if e.tc.Old == nil {
return nil, nil // old is nil for create events
}
return extractField(e.tc.Old, qr.Name), nil
return e.extractField(e.tc.Old, qr.Name), nil
case "new":
if e.tc.New == nil {
return nil, nil // new is nil for delete events
}
return extractField(e.tc.New, qr.Name), nil
return e.extractField(e.tc.New, qr.Name), nil
default:
return nil, fmt.Errorf("unknown qualifier %q", qr.Qualifier)
}
@ -238,7 +238,7 @@ func (e *triggerExecOverride) evalExprRecursive(expr Expr, t *task.Task, allTask
case *QualifiedRef:
return e.resolveQualifiedRef(expr)
case *FieldRef:
return extractField(t, expr.Name), nil
return e.extractField(t, expr.Name), nil
case *BinaryExpr:
leftVal, err := e.evalExpr(expr.Left, t, allTasks)
if err != nil {
@ -342,10 +342,16 @@ func (e *triggerExecOverride) evalInOverride(c *InExpr, t *task.Task, allTasks [
}
if list, ok := collVal.([]interface{}); ok {
// unset field (nil) is not a member of any list
if val == nil {
return c.Negated, nil
}
valStr := normalizeToString(val)
foldCase := isEnumLikeField(e.exprFieldType(c.Value))
found := false
for _, elem := range list {
if normalizeToString(elem) == valStr {
elemStr := normalizeToString(elem)
if foldCase && strings.EqualFold(valStr, elemStr) || !foldCase && valStr == elemStr {
found = true
break
}

View file

@ -1,6 +1,9 @@
package ruki
import "fmt"
import (
"fmt"
"strings"
)
// validate.go — structural validation and semantic type-checking.
@ -187,7 +190,7 @@ func (p *Parser) validateAssignments(assignments []Assignment) error {
if err != nil {
return fmt.Errorf("field %q: %w", a.Field, err)
}
if err := p.checkAssignmentCompat(fs.Type, rhsType, a.Value); err != nil {
if err := p.checkAssignmentCompat(fs, rhsType, a.Value); err != nil {
return fmt.Errorf("field %q: %w", a.Field, err)
}
}
@ -251,7 +254,8 @@ func (p *Parser) validateLimit(limit *int) error {
func isOrderableType(t ValueType) bool {
switch t {
case ValueInt, ValueDate, ValueTimestamp, ValueDuration,
ValueString, ValueStatus, ValueTaskType, ValueID, ValueRef:
ValueString, ValueStatus, ValueTaskType, ValueID, ValueRef,
ValueEnum, ValueBool:
return true
default:
return false
@ -302,6 +306,26 @@ func (p *Parser) validateCompare(c *CompareExpr) error {
// resolve empty from context
leftType, rightType = resolveEmptyPair(leftType, rightType)
// implicit midnight-UTC coercion: timestamp vs date literal
if leftType == ValueTimestamp {
if _, ok := c.Right.(*DateLiteral); ok && rightType == ValueDate {
rightType = ValueTimestamp
}
}
if rightType == ValueTimestamp {
if _, ok := c.Left.(*DateLiteral); ok && leftType == ValueDate {
leftType = ValueTimestamp
}
}
// implicit bool coercion: string literal "true"/"false" vs bool field
if leftType == ValueBool && rightType == ValueString && isBoolStringLiteral(c.Right) {
rightType = ValueBool
}
if rightType == ValueBool && leftType == ValueString && isBoolStringLiteral(c.Left) {
leftType = ValueBool
}
if !typesCompatible(leftType, rightType) {
return fmt.Errorf("cannot compare %s %s %s", typeName(leftType), c.Op, typeName(rightType))
}
@ -314,7 +338,7 @@ func (p *Parser) validateCompare(c *CompareExpr) error {
// use the most specific type for operator and enum validation
enumType := leftType
if rightType == ValueStatus || rightType == ValueTaskType {
if rightType == ValueStatus || rightType == ValueTaskType || rightType == ValueEnum {
enumType = rightType
}
@ -344,11 +368,18 @@ func (p *Parser) validateIn(c *InExpr) error {
}
if !membershipCompatible(valType, elemType) {
ll, isLiteral := c.Collection.(*ListLiteral)
if !isLiteral || !isStringLike(valType) || !allStringLiterals(ll) {
return fmt.Errorf("element type mismatch: %s in %s", typeName(valType), typeName(collType))
// allow enum value checked against a list of string literals
enumInStringList := isLiteral && valType == ValueEnum && allStringLiterals(ll)
// allow bool field checked against a list of bool-string literals
boolInStringList := isLiteral && valType == ValueBool && allBoolStringLiterals(ll)
if !enumInStringList && !boolInStringList {
if !isLiteral || !isStringLike(valType) || !allStringLiterals(ll) {
return fmt.Errorf("element type mismatch: %s in %s", typeName(valType), typeName(collType))
}
}
}
return p.validateEnumListElements(c.Collection, valType)
enumField, _ := exprFieldName(c.Value)
return p.validateEnumListElements(c.Collection, valType, enumField)
}
// substring mode: both sides must be string (not string-like)
@ -421,6 +452,9 @@ func (p *Parser) inferExprType(e Expr) (ValueType, error) {
case *ListLiteral:
return p.inferListType(e)
case *BoolLiteral:
return ValueBool, nil
case *EmptyLiteral:
return -1, nil // sentinel: resolved from context
@ -662,12 +696,32 @@ func (p *Parser) validateEnumLiterals(left, right Expr, resolvedType ValueType)
}
}
}
if resolvedType == ValueEnum {
fieldName, _ := exprFieldName(left)
if fieldName == "" {
fieldName, _ = exprFieldName(right)
}
if fieldName != "" {
if s, ok := right.(*StringLiteral); ok {
if _, valid := p.normalizeEnumValue(fieldName, s.Value); !valid {
return fmt.Errorf("unknown value %q for field %q", s.Value, fieldName)
}
}
if s, ok := left.(*StringLiteral); ok {
if _, valid := p.normalizeEnumValue(fieldName, s.Value); !valid {
return fmt.Errorf("unknown value %q for field %q", s.Value, fieldName)
}
}
}
}
return nil
}
// validateEnumListElements checks string literals inside a list expression
// against the appropriate enum normalizer, based on the value type being checked.
func (p *Parser) validateEnumListElements(collection Expr, valType ValueType) error {
// enumFieldName is only used when valType is ValueEnum — it identifies the custom
// enum field whose AllowedValues should be checked against.
func (p *Parser) validateEnumListElements(collection Expr, valType ValueType, enumFieldName string) error {
ll, ok := collection.(*ListLiteral)
if !ok {
return nil
@ -677,15 +731,21 @@ func (p *Parser) validateEnumListElements(collection Expr, valType ValueType) er
if !ok {
continue
}
if valType == ValueStatus {
switch valType {
case ValueStatus:
if _, valid := p.schema.NormalizeStatus(s.Value); !valid {
return fmt.Errorf("unknown status %q", s.Value)
}
}
if valType == ValueTaskType {
case ValueTaskType:
if _, valid := p.schema.NormalizeType(s.Value); !valid {
return fmt.Errorf("unknown type %q", s.Value)
}
case ValueEnum:
if enumFieldName != "" {
if _, valid := p.normalizeEnumValue(enumFieldName, s.Value); !valid {
return fmt.Errorf("unknown value %q for field %q", s.Value, enumFieldName)
}
}
}
}
return nil
@ -693,7 +753,9 @@ func (p *Parser) validateEnumListElements(collection Expr, valType ValueType) er
// --- assignment compatibility ---
func (p *Parser) checkAssignmentCompat(fieldType, rhsType ValueType, rhs Expr) error {
func (p *Parser) checkAssignmentCompat(fs FieldSpec, rhsType ValueType, rhs Expr) error {
fieldType := fs.Type
// empty is assignable to anything
if _, ok := rhs.(*EmptyLiteral); ok {
return nil
@ -702,16 +764,41 @@ func (p *Parser) checkAssignmentCompat(fieldType, rhsType ValueType, rhs Expr) e
return nil
}
// implicit midnight-UTC coercion: date literal assignable to timestamp field
if fieldType == ValueTimestamp && rhsType == ValueDate {
if _, ok := rhs.(*DateLiteral); ok {
return nil
}
}
// implicit bool coercion: string literal "true"/"false" assignable to bool field
if fieldType == ValueBool && rhsType == ValueString && isBoolStringLiteral(rhs) {
return nil
}
if typesCompatible(fieldType, rhsType) {
// enum fields only accept same-type or string literals
// built-in enum fields only accept same-type or string literals
if (fieldType == ValueStatus || fieldType == ValueTaskType) && rhsType != fieldType {
if _, ok := rhs.(*StringLiteral); !ok {
return fmt.Errorf("cannot assign %s to %s field", typeName(rhsType), typeName(fieldType))
}
}
// custom enum fields only accept same-field enum or string literals
if fieldType == ValueEnum && rhsType != ValueEnum {
if _, ok := rhs.(*StringLiteral); !ok {
return fmt.Errorf("cannot assign %s to %s field", typeName(rhsType), typeName(fieldType))
}
}
if fieldType == ValueEnum && rhsType == ValueEnum {
// reject cross-field enum assignment
rhsField, _ := exprFieldName(rhs)
if rhsField != "" && rhsField != fs.Name {
return fmt.Errorf("cannot assign field %q to enum field %q (different enum domains)", rhsField, fs.Name)
}
}
// non-enum string-like fields reject enum-typed RHS
if (fieldType == ValueString || fieldType == ValueID || fieldType == ValueRef) &&
(rhsType == ValueStatus || rhsType == ValueTaskType) {
(rhsType == ValueStatus || rhsType == ValueTaskType || rhsType == ValueEnum) {
return fmt.Errorf("cannot assign %s to %s field", typeName(rhsType), typeName(fieldType))
}
@ -729,7 +816,7 @@ func (p *Parser) checkAssignmentCompat(fieldType, rhsType ValueType, rhs Expr) e
}
}
// validate enum values
// validate built-in enum values
if fieldType == ValueStatus {
if s, ok := rhs.(*StringLiteral); ok {
if _, valid := p.schema.NormalizeStatus(s.Value); !valid {
@ -744,6 +831,14 @@ func (p *Parser) checkAssignmentCompat(fieldType, rhsType ValueType, rhs Expr) e
}
}
}
// validate custom enum values
if fieldType == ValueEnum {
if s, ok := rhs.(*StringLiteral); ok {
if _, valid := p.normalizeEnumValue(fs.Name, s.Value); !valid {
return fmt.Errorf("unknown value %q for field %q", s.Value, fs.Name)
}
}
}
return nil
}
@ -766,19 +861,20 @@ func typesCompatible(a, b ValueType) bool {
if a == -1 || b == -1 { // unresolved empty
return true
}
// string-like types are compatible with each other
// string-like types are compatible with each other for comparison/assignment
stringLike := map[ValueType]bool{
ValueString: true,
ValueStatus: true,
ValueTaskType: true,
ValueID: true,
ValueRef: true,
ValueEnum: true,
}
return stringLike[a] && stringLike[b]
}
func isEnumType(t ValueType) bool {
return t == ValueStatus || t == ValueTaskType
return t == ValueStatus || t == ValueTaskType || t == ValueEnum
}
// allStringLiterals returns true if every element in the list is a *StringLiteral.
@ -791,10 +887,40 @@ func allStringLiterals(ll *ListLiteral) bool {
return true
}
// isBoolStringLiteral reports whether e is a StringLiteral with value "true" or "false".
// Used to coerce legacy-converted string values into bool-compatible operands.
func isBoolStringLiteral(e Expr) bool {
s, ok := e.(*StringLiteral)
if !ok {
return false
}
return strings.EqualFold(s.Value, "true") || strings.EqualFold(s.Value, "false")
}
// allBoolStringLiterals reports whether every element in the list is a bool-string literal.
func allBoolStringLiterals(ll *ListLiteral) bool {
for _, elem := range ll.Elements {
if !isBoolStringLiteral(elem) {
return false
}
}
return true
}
// checkCompareCompat rejects nonsensical cross-type comparisons in WHERE clauses.
// e.g. status = title (enum vs string field) is rejected,
// but status = "done" (enum vs string literal) is allowed.
func (p *Parser) checkCompareCompat(leftType, rightType ValueType, left, right Expr) error {
// two custom enum fields: must reference the same field
if leftType == ValueEnum && rightType == ValueEnum {
lf, _ := exprFieldName(left)
rf, _ := exprFieldName(right)
if lf != "" && rf != "" && lf != rf {
return fmt.Errorf("cannot compare enum field %q with enum field %q (different enum domains)", lf, rf)
}
return nil
}
if isEnumType(leftType) && rightType != leftType {
if err := checkEnumOperand(leftType, rightType, right); err != nil {
return err
@ -911,6 +1037,8 @@ func typeName(t ValueType) string {
return "status"
case ValueTaskType:
return "type"
case ValueEnum:
return "enum"
case -1:
return "empty"
default:
@ -927,6 +1055,8 @@ func exprContainsFieldRef(expr Expr) bool {
return true
case *QualifiedRef:
return true
case *BoolLiteral:
return false
case *BinaryExpr:
return exprContainsFieldRef(e.Left) || exprContainsFieldRef(e.Right)
case *FunctionCall:
@ -947,3 +1077,36 @@ func exprContainsFieldRef(expr Expr) bool {
return false
}
}
// exprFieldName extracts the field name from a FieldRef or QualifiedRef.
// Returns ("", false) for any other expression type.
//
//nolint:unparam // bool return is used by callers; string is used in enum domain checks
func exprFieldName(expr Expr) (string, bool) {
switch e := expr.(type) {
case *FieldRef:
return e.Name, true
case *QualifiedRef:
return e.Name, true
default:
return "", false
}
}
// normalizeEnumValue validates a raw string against a custom enum field's
// AllowedValues (case-insensitive). Returns the canonical value and true,
// or ("", false) if not found.
//
//nolint:unparam // canonical string return reserved for future use in normalization paths
func (p *Parser) normalizeEnumValue(fieldName, raw string) (string, bool) {
fs, ok := p.schema.Field(fieldName)
if !ok {
return "", false
}
for _, av := range fs.AllowedValues {
if strings.EqualFold(av, raw) {
return av, true
}
}
return "", false
}

View file

@ -1764,14 +1764,14 @@ func TestValidation_CheckCompareOpUnknown(t *testing.T) {
}
func TestValidation_IsOrderableType(t *testing.T) {
orderable := []ValueType{ValueInt, ValueDate, ValueTimestamp, ValueDuration, ValueString, ValueStatus, ValueTaskType, ValueID, ValueRef}
orderable := []ValueType{ValueInt, ValueDate, ValueTimestamp, ValueDuration, ValueString, ValueStatus, ValueTaskType, ValueID, ValueRef, ValueEnum, ValueBool}
for _, vt := range orderable {
if !isOrderableType(vt) {
t.Errorf("expected %s to be orderable", typeName(vt))
}
}
notOrderable := []ValueType{ValueBool, ValueListString, ValueListRef, ValueRecurrence}
notOrderable := []ValueType{ValueListString, ValueListRef, ValueRecurrence}
for _, vt := range notOrderable {
if isOrderableType(vt) {
t.Errorf("expected %s to NOT be orderable", typeName(vt))
@ -2459,7 +2459,7 @@ func TestValidation_InferBinaryExprType_RightError(t *testing.T) {
func TestValidation_CheckAssignmentCompat_UnresolvedEmpty(t *testing.T) {
p := newTestParser()
// rhsType == -1 but rhs is not an EmptyLiteral — exercises the rhsType == -1 branch
err := p.checkAssignmentCompat(ValueString, -1, &BinaryExpr{
err := p.checkAssignmentCompat(FieldSpec{Name: "title", Type: ValueString}, -1, &BinaryExpr{
Op: "+",
Left: &EmptyLiteral{},
Right: &EmptyLiteral{},
@ -2518,3 +2518,237 @@ func TestValidation_InExprListElementTypeError(t *testing.T) {
t.Fatal("expected error for unknown function in list element")
}
}
// --- custom field validation tests ---
// customTestSchema extends testSchema with custom enum, bool, and timestamp fields.
type customTestSchema struct {
testSchema
}
func (customTestSchema) Field(name string) (FieldSpec, bool) {
customFields := map[string]FieldSpec{
"severity": {Name: "severity", Type: ValueEnum, Custom: true, AllowedValues: []string{"low", "medium", "high", "critical"}},
"priority2": {Name: "priority2", Type: ValueEnum, Custom: true, AllowedValues: []string{"p0", "p1", "p2"}},
"approval": {Name: "approval", Type: ValueEnum, Custom: true, AllowedValues: []string{"true", "false", "maybe"}},
"flag": {Name: "flag", Type: ValueBool, Custom: true},
"startedAt": {Name: "startedAt", Type: ValueTimestamp, Custom: true},
"notes": {Name: "notes", Type: ValueString, Custom: true},
"score": {Name: "score", Type: ValueInt, Custom: true},
"labels": {Name: "labels", Type: ValueListString, Custom: true},
"related": {Name: "related", Type: ValueListRef, Custom: true},
}
if f, ok := customFields[name]; ok {
return f, true
}
// delegate to standard test schema for built-in fields
return testSchema{}.Field(name)
}
func newCustomParser() *Parser {
return NewParser(customTestSchema{})
}
func TestCustomEnumComparison(t *testing.T) {
p := newCustomParser()
_, err := p.ParseStatement(`select where severity = "low"`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCustomEnumUnknownValue(t *testing.T) {
p := newCustomParser()
_, err := p.ParseStatement(`select where severity = "unknown"`)
if err == nil {
t.Fatal("expected error for unknown enum value")
}
if !strings.Contains(err.Error(), `unknown value "unknown" for field "severity"`) {
t.Fatalf("expected unknown value error, got: %v", err)
}
}
func TestCustomEnumCrossFieldReject(t *testing.T) {
p := newCustomParser()
_, err := p.ParseStatement(`select where severity = priority2`)
if err == nil {
t.Fatal("expected error comparing two different enum fields")
}
if !strings.Contains(err.Error(), "different enum domains") {
t.Fatalf("expected cross-field enum error, got: %v", err)
}
}
func TestCustomBoolComparison(t *testing.T) {
p := newCustomParser()
tests := []struct {
name string
input string
}{
{"flag = true", `select where flag = true`},
{"flag = false", `select where flag = false`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := p.ParseStatement(tt.input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestCustomBoolOrderBy(t *testing.T) {
p := newCustomParser()
_, err := p.ParseStatement(`select order by flag`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCustomEnumOrderBy(t *testing.T) {
p := newCustomParser()
_, err := p.ParseStatement(`select order by severity`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCustomEnumIn(t *testing.T) {
p := newCustomParser()
tests := []struct {
name string
input string
}{
{"in valid values", `select where severity in ["low", "high"]`},
{"not in valid values", `select where severity not in ["low"]`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := p.ParseStatement(tt.input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestCustomEnumInInvalidValue(t *testing.T) {
p := newCustomParser()
_, err := p.ParseStatement(`select where severity in ["low", "bogus"]`)
if err == nil {
t.Fatal("expected error for invalid enum value in list")
}
if !strings.Contains(err.Error(), `unknown value "bogus" for field "severity"`) {
t.Fatalf("expected unknown value error, got: %v", err)
}
}
func TestDateTimestampCoercion(t *testing.T) {
p := newCustomParser()
// startedAt is a timestamp field; bare date literal should be accepted via coercion
_, err := p.ParseStatement(`select where startedAt >= 2026-04-15`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCustomEnumAssignment(t *testing.T) {
p := newCustomParser()
_, err := p.ParseStatement(`update where id = "X" set severity="high"`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCustomEnumAssignToString(t *testing.T) {
p := newCustomParser()
// assigning an enum field ref to a plain string field should be rejected
_, err := p.ParseStatement(`update where id = "X" set notes=severity`)
if err == nil {
t.Fatal("expected error assigning enum field to string field")
}
if !strings.Contains(err.Error(), "cannot assign") {
t.Fatalf("expected assignment error, got: %v", err)
}
}
func TestBoolStringCoercion_Assignment(t *testing.T) {
p := newCustomParser()
// string "true"/"false" should be assignable to a bool field
for _, stmt := range []string{
`update where id = "X" set flag="true"`,
`update where id = "X" set flag="false"`,
} {
t.Run(stmt, func(t *testing.T) {
if _, err := p.ParseStatement(stmt); err != nil {
t.Errorf("expected success, got: %v", err)
}
})
}
}
func TestBoolStringCoercion_Compare(t *testing.T) {
p := newCustomParser()
// string "true"/"false" should be comparable with a bool field
for _, stmt := range []string{
`select where flag = "true"`,
`select where flag = "false"`,
} {
t.Run(stmt, func(t *testing.T) {
if _, err := p.ParseStatement(stmt); err != nil {
t.Errorf("expected success, got: %v", err)
}
})
}
}
func TestBoolStringCoercion_InList(t *testing.T) {
p := newCustomParser()
// bool field in list of bool-string literals should succeed
stmt := `select where flag in ["true", "false"]`
if _, err := p.ParseStatement(stmt); err != nil {
t.Errorf("expected success, got: %v", err)
}
}
func TestEnumWithTrueFalseValues(t *testing.T) {
p := newCustomParser()
// enum with allowed values ["true", "false", "maybe"] should accept quoted strings
for _, stmt := range []string{
`update where id = "X" set approval="true"`,
`update where id = "X" set approval="false"`,
`update where id = "X" set approval="maybe"`,
`select where approval = "true"`,
`select where approval in ["true", "false"]`,
} {
t.Run(stmt, func(t *testing.T) {
if _, err := p.ParseStatement(stmt); err != nil {
t.Errorf("expected success, got: %v", err)
}
})
}
// bare true should become BoolLiteral and fail for enum field
_, err := p.ParseStatement(`update where id = "X" set approval=true`)
if err == nil {
t.Fatal("expected error assigning bare bool to enum field")
}
}

View file

@ -245,16 +245,17 @@ func (s *InMemoryStore) NewTaskTemplate() (*task.Task, error) {
}
t := &task.Task{
ID: taskID,
Title: "",
Description: "",
Type: task.TypeStory,
Status: task.DefaultStatus(),
Priority: 7, // match embedded template default
Points: 1,
Tags: []string{"idea"},
CreatedAt: time.Now(),
CreatedBy: "memory-user",
ID: taskID,
Title: "",
Description: "",
Type: task.TypeStory,
Status: task.DefaultStatus(),
Priority: 7, // match embedded template default
Points: 1,
Tags: []string{"idea"},
CustomFields: make(map[string]interface{}),
CreatedAt: time.Now(),
CreatedBy: "memory-user",
}
return t, nil
}

View file

@ -13,6 +13,7 @@ import (
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/store/internal/git"
taskpkg "github.com/boolean-maybe/tiki/task"
"github.com/boolean-maybe/tiki/workflow"
"gopkg.in/yaml.v3"
)
@ -114,20 +115,28 @@ func (s *TikiStore) loadTaskFile(path string, authorMap map[string]*git.AuthorIn
}
}
// extract custom fields from frontmatter map
customFields, unknownFields, err := extractCustomFields(fmMap, path)
if err != nil {
return nil, err
}
task := &taskpkg.Task{
ID: taskID,
Title: fm.Title,
Description: strings.TrimSpace(body),
Type: taskpkg.NormalizeType(fm.Type),
Status: taskpkg.MapStatus(fm.Status),
Tags: fm.Tags.ToStringSlice(),
DependsOn: fm.DependsOn.ToStringSlice(),
Due: fm.Due.ToTime(),
Recurrence: fm.Recurrence.ToRecurrence(),
Assignee: fm.Assignee,
Priority: int(fm.Priority),
Points: fm.Points,
LoadedMtime: info.ModTime(),
ID: taskID,
Title: fm.Title,
Description: strings.TrimSpace(body),
Type: taskpkg.NormalizeType(fm.Type),
Status: taskpkg.MapStatus(fm.Status),
Tags: fm.Tags.ToStringSlice(),
DependsOn: fm.DependsOn.ToStringSlice(),
Due: fm.Due.ToTime(),
Recurrence: fm.Recurrence.ToRecurrence(),
Assignee: fm.Assignee,
Priority: int(fm.Priority),
Points: fm.Points,
CustomFields: customFields,
UnknownFields: unknownFields,
LoadedMtime: info.ModTime(),
}
// Validate and default Priority field (1-5 range)
@ -317,6 +326,18 @@ func (s *TikiStore) saveTask(task *taskpkg.Task) error {
var content strings.Builder
content.WriteString("---\n")
content.Write(yamlBytes)
// append custom fields
if len(task.CustomFields) > 0 {
if err := appendCustomFields(&content, task.CustomFields); err != nil {
return fmt.Errorf("marshaling custom fields: %w", err)
}
}
// append unknown fields so they survive round-trips
if len(task.UnknownFields) > 0 {
if err := appendUnknownFields(&content, task.UnknownFields); err != nil {
return fmt.Errorf("marshaling unknown fields: %w", err)
}
}
content.WriteString("---\n")
if task.Description != "" {
content.WriteString(task.Description)
@ -362,3 +383,208 @@ func (s *TikiStore) taskFilePath(id string) string {
filename := strings.ToLower(id) + ".md"
return filepath.Join(s.dir, filename)
}
// builtInFrontmatterKeys lists keys handled by the taskFrontmatter struct.
var builtInFrontmatterKeys = map[string]bool{
"id": true, "title": true, "type": true, "status": true,
"tags": true, "dependsOn": true, "due": true, "recurrence": true,
"assignee": true, "priority": true, "points": true,
}
// extractCustomFields reads custom field values from a raw frontmatter map.
// Built-in keys are skipped. Registered custom fields are coerced and returned
// in the first map. Unrecognised non-builtin keys are preserved verbatim in
// the second map so they survive a load→save round-trip.
func extractCustomFields(fmMap map[string]interface{}, path string) (customFields, unknownFields map[string]interface{}, err error) {
if fmMap == nil {
return nil, nil, nil
}
registryChecked := false
for key, raw := range fmMap {
if builtInFrontmatterKeys[key] {
continue
}
// defer registry check until we actually encounter a non-builtin key
if !registryChecked {
if err := config.RequireWorkflowRegistriesLoaded(); err != nil {
return nil, nil, fmt.Errorf("extractCustomFields for %s: %w", path, err)
}
registryChecked = true
}
fd, ok := workflow.Field(key)
if !ok || !fd.Custom {
slog.Debug("preserving unknown field in frontmatter", "field", key, "file", path)
if unknownFields == nil {
unknownFields = make(map[string]interface{})
}
unknownFields[key] = raw
continue
}
val, err := coerceCustomValue(fd, raw)
if err != nil {
// stale value (e.g. removed enum option): demote to unknown so
// the task still loads and the value survives for repair
slog.Warn("demoting stale custom field value to unknown",
"field", key, "file", path, "error", err)
if unknownFields == nil {
unknownFields = make(map[string]interface{})
}
unknownFields[key] = raw
continue
}
if customFields == nil {
customFields = make(map[string]interface{})
}
customFields[key] = val
}
return customFields, unknownFields, nil
}
// coerceCustomValue converts a raw YAML value to the Go type expected by FieldDef.
func coerceCustomValue(fd workflow.FieldDef, raw interface{}) (interface{}, error) {
switch fd.Type {
case workflow.TypeString:
s, ok := raw.(string)
if !ok {
return nil, fmt.Errorf("expected string, got %T", raw)
}
return s, nil
case workflow.TypeEnum:
s, ok := raw.(string)
if !ok {
return nil, fmt.Errorf("expected string for enum, got %T", raw)
}
for _, av := range fd.AllowedValues {
if strings.EqualFold(s, av) {
return av, nil // canonical casing
}
}
return nil, fmt.Errorf("value %q not in allowed values %v", s, fd.AllowedValues)
case workflow.TypeInt:
switch v := raw.(type) {
case int:
return v, nil
case float64:
if v != float64(int(v)) {
return nil, fmt.Errorf("value %g is not a whole number", v)
}
return int(v), nil
default:
return nil, fmt.Errorf("expected int, got %T", raw)
}
case workflow.TypeBool:
b, ok := raw.(bool)
if !ok {
return nil, fmt.Errorf("expected bool, got %T", raw)
}
return b, nil
case workflow.TypeTimestamp:
switch v := raw.(type) {
case time.Time:
return v, nil
case string:
if t, err := time.Parse(time.RFC3339, v); err == nil {
return t, nil
}
if t, err := time.Parse("2006-01-02", v); err == nil {
return t, nil
}
return nil, fmt.Errorf("cannot parse timestamp %q", v)
default:
return nil, fmt.Errorf("expected time or string for timestamp, got %T", raw)
}
case workflow.TypeListString:
return coerceStringList(raw)
case workflow.TypeListRef:
ss, err := coerceStringList(raw)
if err != nil {
return nil, err
}
for i, s := range ss {
ss[i] = strings.ToUpper(strings.TrimSpace(s))
}
// drop empties
filtered := ss[:0]
for _, s := range ss {
if s != "" {
filtered = append(filtered, s)
}
}
return filtered, nil
default:
return raw, nil
}
}
// coerceStringList converts a raw YAML value ([]interface{}) to []string.
func coerceStringList(raw interface{}) ([]string, error) {
switch v := raw.(type) {
case []interface{}:
ss := make([]string, 0, len(v))
for _, item := range v {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("list item: expected string, got %T", item)
}
ss = append(ss, s)
}
return ss, nil
case []string:
return v, nil
default:
return nil, fmt.Errorf("expected list, got %T", raw)
}
}
// appendCustomFields validates and marshals custom fields into the content builder.
// Keys are written in sorted order after the struct YAML output.
// Uses yaml.Marshal per field so that ambiguous string values (e.g. "true",
// "2026-05-15") are properly quoted and round-trip without type corruption.
func appendCustomFields(w *strings.Builder, fields map[string]interface{}) error {
keys := make([]string, 0, len(fields))
for k := range fields {
fd, ok := workflow.Field(k)
if !ok || !fd.Custom {
return fmt.Errorf("unknown custom field %q", k)
}
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
out, err := yaml.Marshal(map[string]interface{}{k: fields[k]})
if err != nil {
return fmt.Errorf("marshaling field %q: %w", k, err)
}
w.Write(out)
}
return nil
}
// appendUnknownFields writes preserved unknown frontmatter keys back in sorted
// order. These are keys that were present in the file but don't match any
// currently registered custom field — preserved so they survive round-trips.
func appendUnknownFields(w *strings.Builder, fields map[string]interface{}) error {
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
out, err := yaml.Marshal(map[string]interface{}{k: fields[k]})
if err != nil {
return fmt.Errorf("marshaling unknown field %q: %w", k, err)
}
w.Write(out)
}
return nil
}

View file

@ -8,7 +8,9 @@ import (
"testing"
"time"
"github.com/boolean-maybe/tiki/config"
taskpkg "github.com/boolean-maybe/tiki/task"
"github.com/boolean-maybe/tiki/workflow"
)
func TestSortTasks(t *testing.T) {
@ -951,3 +953,479 @@ func TestSaveTask_Due(t *testing.T) {
})
}
}
func TestCustomFieldRoundTrip(t *testing.T) {
config.MarkRegistriesLoadedForTest()
// register custom fields
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "severity", Type: workflow.TypeEnum, AllowedValues: []string{"low", "medium", "high"}},
{Name: "score", Type: workflow.TypeInt},
{Name: "active", Type: workflow.TypeBool},
{Name: "notes", Type: workflow.TypeString},
}); err != nil {
t.Fatalf("register custom fields: %v", err)
}
t.Cleanup(func() { workflow.ClearCustomFields() })
tmpDir := t.TempDir()
store, err := NewTikiStore(tmpDir)
if err != nil {
t.Fatalf("NewTikiStore: %v", err)
}
original := &taskpkg.Task{
ID: "TIKI-CUSTOM",
Title: "Custom field test",
Status: taskpkg.StatusReady,
Type: "story",
Priority: 2,
CustomFields: map[string]interface{}{
"severity": "high",
"score": 42,
"active": true,
"notes": "some notes here",
},
}
// save
if err := store.saveTask(original); err != nil {
t.Fatalf("saveTask: %v", err)
}
// reload
path := store.taskFilePath(original.ID)
loaded, err := store.loadTaskFile(path, nil, nil)
if err != nil {
t.Fatalf("loadTaskFile: %v", err)
}
// verify custom fields survived
if loaded.CustomFields == nil {
t.Fatal("CustomFields is nil after reload")
}
if loaded.CustomFields["severity"] != "high" {
t.Errorf("severity = %v, want high", loaded.CustomFields["severity"])
}
if loaded.CustomFields["score"] != 42 {
t.Errorf("score = %v, want 42", loaded.CustomFields["score"])
}
if loaded.CustomFields["active"] != true {
t.Errorf("active = %v, want true", loaded.CustomFields["active"])
}
if loaded.CustomFields["notes"] != "some notes here" {
t.Errorf("notes = %v, want 'some notes here'", loaded.CustomFields["notes"])
}
// verify built-in fields are also correct
if loaded.Title != "Custom field test" {
t.Errorf("title = %q, want %q", loaded.Title, "Custom field test")
}
if loaded.Priority != 2 {
t.Errorf("priority = %d, want 2", loaded.Priority)
}
}
func TestCustomFieldRoundTrip_AmbiguousStrings(t *testing.T) {
config.MarkRegistriesLoadedForTest()
t.Cleanup(func() { workflow.ClearCustomFields() })
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "notes", Type: workflow.TypeString},
{Name: "labels", Type: workflow.TypeListString},
{Name: "yesno", Type: workflow.TypeEnum, AllowedValues: []string{"true", "false", "maybe"}},
}); err != nil {
t.Fatalf("register: %v", err)
}
tests := []struct {
name string
fields map[string]interface{}
check func(t *testing.T, cf map[string]interface{})
}{
{
name: "string that looks like bool",
fields: map[string]interface{}{"notes": "true"},
check: func(t *testing.T, cf map[string]interface{}) {
if v, ok := cf["notes"].(string); !ok || v != "true" {
t.Errorf("notes = %v (%T), want string \"true\"", cf["notes"], cf["notes"])
}
},
},
{
name: "string that looks like date",
fields: map[string]interface{}{"notes": "2026-05-15"},
check: func(t *testing.T, cf map[string]interface{}) {
if v, ok := cf["notes"].(string); !ok || v != "2026-05-15" {
t.Errorf("notes = %v (%T), want string \"2026-05-15\"", cf["notes"], cf["notes"])
}
},
},
{
name: "string with colon",
fields: map[string]interface{}{"notes": "a: b"},
check: func(t *testing.T, cf map[string]interface{}) {
if v, ok := cf["notes"].(string); !ok || v != "a: b" {
t.Errorf("notes = %v (%T), want string \"a: b\"", cf["notes"], cf["notes"])
}
},
},
{
name: "string that looks like int",
fields: map[string]interface{}{"notes": "42"},
check: func(t *testing.T, cf map[string]interface{}) {
if v, ok := cf["notes"].(string); !ok || v != "42" {
t.Errorf("notes = %v (%T), want string \"42\"", cf["notes"], cf["notes"])
}
},
},
{
name: "list with ambiguous items",
fields: map[string]interface{}{"labels": []string{"true", "42", "2026-05-15"}},
check: func(t *testing.T, cf map[string]interface{}) {
want := []string{"true", "42", "2026-05-15"}
got, ok := cf["labels"].([]string)
if !ok {
t.Fatalf("labels type = %T, want []string", cf["labels"])
}
if !reflect.DeepEqual(got, want) {
t.Errorf("labels = %v, want %v", got, want)
}
},
},
{
name: "enum value that looks like bool",
fields: map[string]interface{}{"yesno": "true"},
check: func(t *testing.T, cf map[string]interface{}) {
if v, ok := cf["yesno"].(string); !ok || v != "true" {
t.Errorf("yesno = %v (%T), want string \"true\"", cf["yesno"], cf["yesno"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
store, err := NewTikiStore(tmpDir)
if err != nil {
t.Fatalf("NewTikiStore: %v", err)
}
original := &taskpkg.Task{
ID: "TIKI-AMBIG1",
Title: "Ambiguous round-trip",
Status: taskpkg.StatusReady,
Type: "story",
Priority: 2,
CustomFields: tt.fields,
}
if err := store.saveTask(original); err != nil {
t.Fatalf("saveTask: %v", err)
}
path := store.taskFilePath(original.ID)
loaded, err := store.loadTaskFile(path, nil, nil)
if err != nil {
t.Fatalf("loadTaskFile: %v", err)
}
if loaded.CustomFields == nil {
t.Fatal("CustomFields is nil after reload")
}
tt.check(t, loaded.CustomFields)
})
}
}
func TestExtractCustomFields_PreservesStaleKeys(t *testing.T) {
config.MarkRegistriesLoadedForTest()
t.Cleanup(func() { workflow.ClearCustomFields() })
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "severity", Type: workflow.TypeEnum, AllowedValues: []string{"low", "medium", "high"}},
}); err != nil {
t.Fatalf("register: %v", err)
}
fmMap := map[string]interface{}{
"title": "Test",
"status": "ready",
"severity": "high",
"removed_field": "old_value",
}
custom, unknown, err := extractCustomFields(fmMap, "test.md")
if err != nil {
t.Fatalf("extractCustomFields returned error: %v", err)
}
if custom["severity"] != "high" {
t.Errorf("severity = %v, want high", custom["severity"])
}
if _, exists := custom["removed_field"]; exists {
t.Error("stale key 'removed_field' should not appear in custom fields")
}
if unknown["removed_field"] != "old_value" {
t.Errorf("unknown[removed_field] = %v, want old_value", unknown["removed_field"])
}
}
func TestLoadTaskFile_StaleCustomField(t *testing.T) {
config.MarkRegistriesLoadedForTest()
t.Cleanup(func() { workflow.ClearCustomFields() })
// register only "severity" — the file will also contain "old_field"
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "severity", Type: workflow.TypeEnum, AllowedValues: []string{"low", "medium", "high"}},
}); err != nil {
t.Fatalf("register: %v", err)
}
tmpDir := t.TempDir()
store, err := NewTikiStore(tmpDir)
if err != nil {
t.Fatalf("NewTikiStore: %v", err)
}
// write a file with a stale custom field
content := `---
title: Stale field test
type: story
status: ready
priority: 2
severity: high
old_field: leftover_value
---
Description here`
filePath := filepath.Join(tmpDir, "tiki-stale1.md")
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
t.Fatalf("write file: %v", err)
}
loaded, err := store.loadTaskFile(filePath, nil, nil)
if err != nil {
t.Fatalf("loadTaskFile should succeed with stale field, got: %v", err)
}
if loaded.ID != "TIKI-STALE1" {
t.Errorf("ID = %q, want TIKI-STALE1", loaded.ID)
}
if loaded.CustomFields == nil || loaded.CustomFields["severity"] != "high" {
t.Errorf("severity = %v, want high", loaded.CustomFields["severity"])
}
if _, exists := loaded.CustomFields["old_field"]; exists {
t.Error("stale key 'old_field' should not appear in CustomFields")
}
if loaded.UnknownFields == nil || loaded.UnknownFields["old_field"] != "leftover_value" {
t.Errorf("UnknownFields[old_field] = %v, want leftover_value", loaded.UnknownFields["old_field"])
}
}
func TestExtractCustomFields_StaleEnumValueDemotedToUnknown(t *testing.T) {
config.MarkRegistriesLoadedForTest()
t.Cleanup(func() { workflow.ClearCustomFields() })
// register severity with only "low" and "medium" — "high" was removed
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "severity", Type: workflow.TypeEnum, AllowedValues: []string{"low", "medium"}},
}); err != nil {
t.Fatalf("register: %v", err)
}
fmMap := map[string]interface{}{
"title": "Test",
"status": "ready",
"severity": "high", // stale value no longer in allowed values
}
custom, unknown, err := extractCustomFields(fmMap, "test.md")
if err != nil {
t.Fatalf("extractCustomFields should not error on stale enum value, got: %v", err)
}
if _, exists := custom["severity"]; exists {
t.Error("stale enum value should not appear in custom fields")
}
if unknown == nil || unknown["severity"] != "high" {
t.Errorf("unknown[severity] = %v, want 'high' (preserved for repair)", unknown["severity"])
}
}
func TestLoadTaskFile_StaleEnumValue_TaskStillLoads(t *testing.T) {
config.MarkRegistriesLoadedForTest()
t.Cleanup(func() { workflow.ClearCustomFields() })
// register severity without "critical"
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "severity", Type: workflow.TypeEnum, AllowedValues: []string{"low", "medium", "high"}},
}); err != nil {
t.Fatalf("register: %v", err)
}
tmpDir := t.TempDir()
store, err := NewTikiStore(tmpDir)
if err != nil {
t.Fatalf("NewTikiStore: %v", err)
}
content := `---
title: Task with stale enum
type: story
status: ready
priority: 2
severity: critical
---
Description`
filePath := filepath.Join(tmpDir, "tiki-stale2.md")
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
t.Fatalf("write file: %v", err)
}
loaded, err := store.loadTaskFile(filePath, nil, nil)
if err != nil {
t.Fatalf("loadTaskFile should succeed with stale enum value, got: %v", err)
}
if loaded.ID != "TIKI-STALE2" {
t.Errorf("ID = %q, want TIKI-STALE2", loaded.ID)
}
// stale value should be in UnknownFields, not CustomFields
if _, exists := loaded.CustomFields["severity"]; exists {
t.Error("stale enum value should not be in CustomFields")
}
if loaded.UnknownFields == nil || loaded.UnknownFields["severity"] != "critical" {
t.Errorf("UnknownFields[severity] = %v, want 'critical'", loaded.UnknownFields["severity"])
}
}
func TestCoerceCustomValue_IntRejectsFractional(t *testing.T) {
fd := workflow.FieldDef{Name: "score", Type: workflow.TypeInt}
_, err := coerceCustomValue(fd, 1.5)
if err == nil {
t.Fatal("expected error for fractional float, got nil")
}
if !strings.Contains(err.Error(), "not a whole number") {
t.Errorf("error = %q, want substring %q", err.Error(), "not a whole number")
}
}
func TestCoerceCustomValue_IntAcceptsWholeFloat(t *testing.T) {
fd := workflow.FieldDef{Name: "score", Type: workflow.TypeInt}
val, err := coerceCustomValue(fd, 3.0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != 3 {
t.Errorf("val = %v, want 3", val)
}
}
func TestExtractCustomFields_ErrorWithoutRegistry(t *testing.T) {
config.ResetRegistriesLoadedForTest()
t.Cleanup(func() {
config.MarkRegistriesLoadedForTest()
workflow.ClearCustomFields()
})
fmMap := map[string]interface{}{
"title": "Test",
"status": "ready",
"severity": "high",
}
_, _, err := extractCustomFields(fmMap, "test.md")
if err == nil {
t.Fatal("expected error when registries not loaded, got nil")
}
if !strings.Contains(err.Error(), "workflow registries not loaded") {
t.Errorf("error = %q, want substring %q", err.Error(), "workflow registries not loaded")
}
}
func TestExtractCustomFields_OnlyBuiltinKeys_NoRegistryRequired(t *testing.T) {
config.ResetRegistriesLoadedForTest()
t.Cleanup(func() {
config.MarkRegistriesLoadedForTest()
workflow.ClearCustomFields()
})
// frontmatter with only built-in keys — should not require registry
fmMap := map[string]interface{}{
"title": "Test",
"status": "ready",
"priority": 2,
}
custom, unknown, err := extractCustomFields(fmMap, "test.md")
if err != nil {
t.Fatalf("extractCustomFields should succeed with only built-in keys: %v", err)
}
if len(custom) != 0 {
t.Errorf("expected no custom fields, got %v", custom)
}
if len(unknown) != 0 {
t.Errorf("expected no unknown fields, got %v", unknown)
}
}
func TestExtractCustomFields_NilMap_NoRegistryRequired(t *testing.T) {
config.ResetRegistriesLoadedForTest()
t.Cleanup(func() {
config.MarkRegistriesLoadedForTest()
workflow.ClearCustomFields()
})
custom, unknown, err := extractCustomFields(nil, "test.md")
if err != nil {
t.Fatalf("extractCustomFields(nil) should succeed: %v", err)
}
if custom != nil || unknown != nil {
t.Errorf("expected nils for nil input, got custom=%v unknown=%v", custom, unknown)
}
}
func TestSaveTask_PreservesUnknownFields(t *testing.T) {
config.MarkRegistriesLoadedForTest()
t.Cleanup(func() { workflow.ClearCustomFields() })
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "severity", Type: workflow.TypeEnum, AllowedValues: []string{"low", "medium", "high"}},
}); err != nil {
t.Fatalf("register: %v", err)
}
tmpDir := t.TempDir()
store, err := NewTikiStore(tmpDir)
if err != nil {
t.Fatalf("NewTikiStore: %v", err)
}
// write a file with a known custom field and an unknown field
content := "---\ntitle: Roundtrip test\ntype: story\nstatus: ready\npriority: 2\npoints: 3\nseverity: high\nold_field: leftover\n---\nBody text"
filePath := filepath.Join(tmpDir, "tiki-round1.md")
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
t.Fatalf("write file: %v", err)
}
// load, then save without modification
loaded, err := store.loadTaskFile(filePath, nil, nil)
if err != nil {
t.Fatalf("loadTaskFile: %v", err)
}
if err := store.saveTask(loaded); err != nil {
t.Fatalf("saveTask: %v", err)
}
// re-read the file and verify the unknown field survived
data, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
fileContent := string(data)
if !strings.Contains(fileContent, "old_field: leftover") {
t.Errorf("unknown field lost after round-trip:\n%s", fileContent)
}
if !strings.Contains(fileContent, "severity: high") {
t.Errorf("custom field lost after round-trip:\n%s", fileContent)
}
}

View file

@ -13,6 +13,8 @@ import (
"gopkg.in/yaml.v3"
)
const templateSource = "<template>"
// templateFrontmatter represents the YAML frontmatter in template files
type templateFrontmatter struct {
Title string `yaml:"title"`
@ -29,7 +31,7 @@ type templateFrontmatter struct {
// loadTemplateTask reads new.md from the highest-priority location
// (cwd > .doc/ > user config), or falls back to the embedded template.
func loadTemplateTask() *taskpkg.Task {
func loadTemplateTask() (*taskpkg.Task, error) {
templatePath := config.FindTemplateFile()
if templatePath == "" {
@ -48,25 +50,25 @@ func loadTemplateTask() *taskpkg.Task {
}
// loadEmbeddedTemplate loads the embedded config/new.md template
func loadEmbeddedTemplate() *taskpkg.Task {
func loadEmbeddedTemplate() (*taskpkg.Task, error) {
templateStr := config.GetDefaultNewTaskTemplate()
if templateStr == "" {
return nil
return nil, nil
}
return parseTaskTemplate([]byte(templateStr))
}
// parseTaskTemplate parses task template data from markdown with YAML frontmatter
func parseTaskTemplate(data []byte) *taskpkg.Task {
func parseTaskTemplate(data []byte) (*taskpkg.Task, error) {
content := strings.TrimSpace(string(data))
if !strings.HasPrefix(content, "---") {
return nil
return nil, nil
}
rest := content[3:]
idx := strings.Index(rest, "\n---")
if idx == -1 {
return nil
return nil, nil
}
frontmatter := strings.TrimSpace(rest[:idx])
@ -74,7 +76,7 @@ func parseTaskTemplate(data []byte) *taskpkg.Task {
var fm templateFrontmatter
if err := yaml.Unmarshal([]byte(frontmatter), &fm); err != nil {
return nil
return nil, nil
}
// Parse due date if provided
@ -94,19 +96,30 @@ func parseTaskTemplate(data []byte) *taskpkg.Task {
}
}
return &taskpkg.Task{
Title: fm.Title,
Description: body,
Type: taskpkg.NormalizeType(fm.Type),
Status: taskpkg.NormalizeStatus(fm.Status),
Tags: fm.Tags,
DependsOn: fm.DependsOn,
Due: dueTime,
Recurrence: recurrence,
Assignee: fm.Assignee,
Priority: fm.Priority,
Points: fm.Points,
// second pass: extract custom fields from frontmatter map
var fmMap map[string]interface{}
if err := yaml.Unmarshal([]byte(frontmatter), &fmMap); err != nil {
return nil, nil
}
customFields, _, err := extractCustomFields(fmMap, templateSource)
if err != nil {
return nil, fmt.Errorf("template custom fields: %w", err)
}
return &taskpkg.Task{
Title: fm.Title,
Description: body,
Type: taskpkg.NormalizeType(fm.Type),
Status: taskpkg.NormalizeStatus(fm.Status),
Tags: fm.Tags,
DependsOn: fm.DependsOn,
Due: dueTime,
Recurrence: recurrence,
Assignee: fm.Assignee,
Priority: fm.Priority,
Points: fm.Points,
CustomFields: customFields,
}, nil
}
// setAuthorFromGit best-effort populates CreatedBy using current git user.
@ -154,7 +167,10 @@ func (s *TikiStore) NewTaskTemplate() (*taskpkg.Task, error) {
taskID = normalizeTaskID(taskID)
// Load template (with defaults)
template := loadTemplateTask()
template, err := loadTemplateTask()
if err != nil {
return nil, fmt.Errorf("loading template: %w", err)
}
// Create base task with defaults
task := &taskpkg.Task{
@ -183,6 +199,19 @@ func (s *TikiStore) NewTaskTemplate() (*taskpkg.Task, error) {
task.Status = template.Status
}
if template != nil && template.CustomFields != nil {
task.CustomFields = make(map[string]interface{}, len(template.CustomFields))
for k, v := range template.CustomFields {
if ss, ok := v.([]string); ok {
cp := make([]string, len(ss))
copy(cp, ss)
task.CustomFields[k] = cp
} else {
task.CustomFields[k] = v
}
}
}
// Ensure type has a value (fallback if template didn't provide)
if task.Type == "" {
task.Type = taskpkg.TypeStory

View file

@ -6,6 +6,7 @@ import (
"testing"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/workflow"
)
func TestLoadTemplateTask_CwdWins(t *testing.T) {
@ -34,7 +35,10 @@ func TestLoadTemplateTask_CwdWins(t *testing.T) {
config.ResetPathManager()
task := loadTemplateTask()
task, err := loadTemplateTask()
if err != nil {
t.Fatalf("loadTemplateTask() error: %v", err)
}
if task == nil {
t.Fatal("loadTemplateTask() returned nil")
return
@ -62,7 +66,10 @@ func TestLoadTemplateTask_EmbeddedFallback(t *testing.T) {
config.ResetPathManager()
task := loadTemplateTask()
task, err := loadTemplateTask()
if err != nil {
t.Fatalf("loadTemplateTask() error: %v", err)
}
if task == nil {
t.Fatal("loadTemplateTask() returned nil, expected embedded template")
}
@ -74,3 +81,35 @@ func TestLoadTemplateTask_EmbeddedFallback(t *testing.T) {
t.Errorf("status = %q, want \"backlog\" from embedded template", task.Status)
}
}
func TestParseTaskTemplate_BadCustomFieldDemotedToUnknown(t *testing.T) {
config.MarkRegistriesLoadedForTest()
// register an int custom field so we can provoke a coercion failure
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
{Name: "score", Type: workflow.TypeInt},
}); err != nil {
t.Fatalf("register custom fields: %v", err)
}
t.Cleanup(func() { workflow.ClearCustomFields() })
// template has valid built-in fields AND an invalid custom field (string for int)
tmpl := []byte("---\ntitle: keep me\npriority: 3\ntype: bug\nstatus: backlog\nscore: not_a_number\n---\nsome description")
task, err := parseTaskTemplate(tmpl)
if err != nil {
t.Fatalf("template should load despite bad custom field, got: %v", err)
}
if task == nil {
t.Fatal("expected non-nil task")
}
// built-in fields should still be populated
if task.Title != "keep me" {
t.Errorf("title = %q, want %q", task.Title, "keep me")
}
// bad custom field value should not appear in CustomFields
if task.CustomFields != nil {
if _, exists := task.CustomFields["score"]; exists {
t.Error("bad custom field value should not appear in CustomFields")
}
}
}

View file

@ -4,23 +4,25 @@ import "time"
// Task represents a work item (user story, bug, etc.)
type Task struct {
ID string
Title string
Description string
Type Type
Status Status
Tags []string
DependsOn []string
Due time.Time
Recurrence Recurrence
Assignee string
Priority int // lower = higher priority
Points int
Comments []Comment
CreatedBy string // User who initially created the task
CreatedAt time.Time
UpdatedAt time.Time
LoadedMtime time.Time // File mtime when loaded (for optimistic locking)
ID string
Title string
Description string
Type Type
Status Status
Tags []string
DependsOn []string
Due time.Time
Recurrence Recurrence
Assignee string
Priority int // lower = higher priority
Points int
Comments []Comment
CreatedBy string // User who initially created the task
CreatedAt time.Time
UpdatedAt time.Time
CustomFields map[string]interface{} // user-defined fields from workflow.yaml
UnknownFields map[string]interface{} // non-builtin keys not matching any registered custom field
LoadedMtime time.Time // File mtime when loaded (for optimistic locking)
}
// Clone creates a deep copy of the task
@ -62,6 +64,32 @@ func (t *Task) Clone() *Task {
copy(clone.Comments, t.Comments)
}
if t.CustomFields != nil {
clone.CustomFields = make(map[string]interface{}, len(t.CustomFields))
for k, v := range t.CustomFields {
if ss, ok := v.([]string); ok {
cp := make([]string, len(ss))
copy(cp, ss)
clone.CustomFields[k] = cp
} else {
clone.CustomFields[k] = v
}
}
}
if t.UnknownFields != nil {
clone.UnknownFields = make(map[string]interface{}, len(t.UnknownFields))
for k, v := range t.UnknownFields {
if ss, ok := v.([]string); ok {
cp := make([]string, len(ss))
copy(cp, ss)
clone.UnknownFields[k] = cp
} else {
clone.UnknownFields[k] = v
}
}
}
return clone
}

View file

@ -56,8 +56,8 @@ func NewTestApp(t *testing.T) *TestApp {
if err := config.InstallDefaultWorkflow(); err != nil {
t.Fatalf("failed to install default workflow for test: %v", err)
}
if err := config.LoadStatusRegistry(); err != nil {
t.Fatalf("failed to load status registry for test: %v", err)
if err := config.LoadWorkflowRegistries(); err != nil {
t.Fatalf("failed to load workflow registries for test: %v", err)
}
t.Cleanup(func() {
config.ClearStatusRegistry()

View file

@ -2,6 +2,9 @@ package workflow
import (
"fmt"
"regexp"
"strings"
"sync"
"github.com/boolean-maybe/tiki/ruki/keyword"
)
@ -23,12 +26,15 @@ const (
TypeListRef // []string of task ID references (e.g. dependsOn)
TypeStatus // workflow status enum backed by StatusRegistry
TypeTaskType // task type enum backed by TypeRegistry
TypeEnum // custom enum backed by FieldDef.AllowedValues
)
// FieldDef describes a single task field's name and semantic type.
type FieldDef struct {
Name string
Type ValueType
Name string
Type ValueType
Custom bool // true for user-defined fields from workflow.yaml
AllowedValues []string // non-nil only for TypeEnum fields
}
// fieldCatalog is the authoritative list of DSL-visible task fields.
@ -63,23 +69,143 @@ func init() {
}
}
// validIdentRE validates that field names are usable as ruki identifiers.
var validIdentRE = regexp.MustCompile(keyword.IdentPattern)
// custom field registry state
var (
customMu sync.RWMutex
customFields []FieldDef
customFieldByName map[string]FieldDef
onCustomFieldsChanged []func() // callbacks invoked after registry changes
)
// Field returns the FieldDef for a given field name and whether it exists.
// Checks built-in fields first, then custom fields.
func Field(name string) (FieldDef, bool) {
f, ok := fieldByName[name]
return f, ok
if f, ok := fieldByName[name]; ok {
return f, ok
}
customMu.RLock()
defer customMu.RUnlock()
if f, ok := customFieldByName[name]; ok {
return deepCopyFieldDef(f), true
}
return FieldDef{}, false
}
// ValidateFieldName rejects names that collide with ruki reserved keywords.
// ValidateFieldName rejects names that collide with ruki reserved keywords,
// boolean literal identifiers, or characters not valid in ruki identifiers.
func ValidateFieldName(name string) error {
if !validIdentRE.MatchString(name) {
return fmt.Errorf("field name %q is not a valid identifier (must match %s)", name, keyword.IdentPattern)
}
if keyword.IsReserved(name) {
return fmt.Errorf("field name %q is reserved", name)
}
lower := strings.ToLower(name)
if lower == "true" || lower == "false" {
return fmt.Errorf("field name %q is reserved (boolean literal)", name)
}
return nil
}
// Fields returns the ordered list of all DSL-visible task fields.
// Fields returns the ordered list of all DSL-visible task fields
// (built-in + custom). Returns deep copies so callers cannot mutate registry state.
func Fields() []FieldDef {
result := make([]FieldDef, len(fieldCatalog))
copy(result, fieldCatalog)
customMu.RLock()
defer customMu.RUnlock()
result := make([]FieldDef, 0, len(fieldCatalog)+len(customFields))
for _, f := range fieldCatalog {
result = append(result, f) // built-ins have no mutable slices
}
for _, f := range customFields {
result = append(result, deepCopyFieldDef(f))
}
return result
}
// RegisterCustomFields validates and registers custom field definitions.
// Deep-copies incoming defs so the caller retains no mutable alias into registry state.
// Returns an error if any definition collides with built-in fields or reserved keywords.
func RegisterCustomFields(defs []FieldDef) error {
// build case-insensitive lookup of built-in names
builtInLower := make(map[string]string, len(fieldByName))
for name := range fieldByName {
builtInLower[strings.ToLower(name)] = name
}
byName := make(map[string]FieldDef, len(defs))
seenLower := make(map[string]string, len(defs))
copied := make([]FieldDef, 0, len(defs))
for _, d := range defs {
if err := ValidateFieldName(d.Name); err != nil {
return fmt.Errorf("custom field %q: %w", d.Name, err)
}
lower := strings.ToLower(d.Name)
if builtIn, ok := builtInLower[lower]; ok {
return fmt.Errorf("custom field %q collides with built-in field %q (case-insensitive)", d.Name, builtIn)
}
if prev, ok := seenLower[lower]; ok {
return fmt.Errorf("custom field %q collides with %q (case-insensitive)", d.Name, prev)
}
seenLower[lower] = d.Name
if d.Type == TypeEnum && len(d.AllowedValues) == 0 {
return fmt.Errorf("custom enum field %q requires non-empty values", d.Name)
}
c := FieldDef{
Name: d.Name,
Type: d.Type,
Custom: true,
}
if d.AllowedValues != nil {
c.AllowedValues = make([]string, len(d.AllowedValues))
copy(c.AllowedValues, d.AllowedValues)
}
byName[d.Name] = c
copied = append(copied, c)
}
customMu.Lock()
customFields = copied
customFieldByName = byName
customMu.Unlock()
notifyCustomFieldsChanged()
return nil
}
// ClearCustomFields removes all custom field registrations. Intended for tests.
func ClearCustomFields() {
customMu.Lock()
customFields = nil
customFieldByName = nil
customMu.Unlock()
notifyCustomFieldsChanged()
}
// OnCustomFieldsChanged registers a callback invoked whenever the custom field
// registry is modified (register or clear). Used by plugin/legacy_convert to
// invalidate its field-name cache without an import cycle.
func OnCustomFieldsChanged(fn func()) {
customMu.Lock()
onCustomFieldsChanged = append(onCustomFieldsChanged, fn)
customMu.Unlock()
}
func notifyCustomFieldsChanged() {
customMu.RLock()
cbs := onCustomFieldsChanged
customMu.RUnlock()
for _, fn := range cbs {
fn()
}
}
// deepCopyFieldDef returns a copy of fd with a cloned AllowedValues slice.
func deepCopyFieldDef(fd FieldDef) FieldDef {
if fd.AllowedValues != nil {
vals := make([]string, len(fd.AllowedValues))
copy(vals, fd.AllowedValues)
fd.AllowedValues = vals
}
return fd
}

View file

@ -109,3 +109,197 @@ func TestValidateFieldName_AcceptsNonKeywords(t *testing.T) {
})
}
}
func TestValidateFieldName_RejectsInvalidIdentifiers(t *testing.T) {
for _, name := range []string{"customer-id", "customer id", "9score", "a.b", ""} {
t.Run(name, func(t *testing.T) {
err := ValidateFieldName(name)
if err == nil {
t.Errorf("expected error for invalid identifier %q", name)
}
})
}
}
func TestValidateFieldName_AcceptsValidIdentifiers(t *testing.T) {
for _, name := range []string{"_private", "score2", "myField", "A", "_"} {
t.Run(name, func(t *testing.T) {
err := ValidateFieldName(name)
if err != nil {
t.Errorf("unexpected error for %q: %v", name, err)
}
})
}
}
func TestRegisterCustomFields(t *testing.T) {
t.Cleanup(func() { ClearCustomFields() })
defs := []FieldDef{
{Name: "notes", Type: TypeString},
{Name: "score", Type: TypeInt},
{Name: "active", Type: TypeBool},
{Name: "startedAt", Type: TypeTimestamp},
{Name: "severity", Type: TypeEnum, AllowedValues: []string{"low", "medium", "high"}},
{Name: "labels", Type: TypeListString},
{Name: "related", Type: TypeListRef},
}
if err := RegisterCustomFields(defs); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// verify Field() finds each custom field
for _, d := range defs {
f, ok := Field(d.Name)
if !ok {
t.Errorf("Field(%q) not found", d.Name)
continue
}
if f.Type != d.Type {
t.Errorf("Field(%q).Type = %v, want %v", d.Name, f.Type, d.Type)
}
if !f.Custom {
t.Errorf("Field(%q).Custom = false, want true", d.Name)
}
}
// verify Fields() includes custom fields
all := Fields()
if len(all) != 15+len(defs) {
t.Errorf("Fields() length = %d, want %d", len(all), 15+len(defs))
}
// verify enum AllowedValues
sev, _ := Field("severity")
if len(sev.AllowedValues) != 3 || sev.AllowedValues[0] != "low" {
t.Errorf("severity.AllowedValues = %v, want [low medium high]", sev.AllowedValues)
}
}
func TestRegisterCustomFields_Collisions(t *testing.T) {
t.Cleanup(func() { ClearCustomFields() })
// each built-in field name should be rejected
for _, name := range []string{"id", "title", "status", "priority", "tags", "due"} {
t.Run(name, func(t *testing.T) {
err := RegisterCustomFields([]FieldDef{{Name: name, Type: TypeString}})
if err == nil {
t.Errorf("expected error for collision with built-in %q", name)
}
})
}
}
func TestRegisterCustomFields_CaseInsensitiveBuiltinCollision(t *testing.T) {
t.Cleanup(func() { ClearCustomFields() })
for _, name := range []string{"Title", "STATUS", "DependsOn", "DEPENDSON", "CreatedAt", "CREATEDAT"} {
t.Run(name, func(t *testing.T) {
err := RegisterCustomFields([]FieldDef{{Name: name, Type: TypeString}})
if err == nil {
t.Errorf("expected error for case-insensitive collision with built-in for %q", name)
}
})
}
}
func TestRegisterCustomFields_CaseInsensitiveBatchCollision(t *testing.T) {
t.Cleanup(func() { ClearCustomFields() })
err := RegisterCustomFields([]FieldDef{
{Name: "myField", Type: TypeString},
{Name: "MyField", Type: TypeInt},
})
if err == nil {
t.Fatal("expected error for case-insensitive collision between custom fields")
}
}
func TestRegisterCustomFields_ReservedKeyword(t *testing.T) {
t.Cleanup(func() { ClearCustomFields() })
for _, name := range []string{"select", "where", "and", "order"} {
t.Run(name, func(t *testing.T) {
err := RegisterCustomFields([]FieldDef{{Name: name, Type: TypeString}})
if err == nil {
t.Errorf("expected error for reserved keyword %q", name)
}
})
}
}
func TestRegisterCustomFields_TrueFalseRejected(t *testing.T) {
t.Cleanup(func() { ClearCustomFields() })
for _, name := range []string{"true", "false", "True", "FALSE"} {
t.Run(name, func(t *testing.T) {
err := RegisterCustomFields([]FieldDef{{Name: name, Type: TypeString}})
if err == nil {
t.Errorf("expected error for boolean literal name %q", name)
}
})
}
}
func TestRegisterCustomFields_EnumRequiresValues(t *testing.T) {
t.Cleanup(func() { ClearCustomFields() })
err := RegisterCustomFields([]FieldDef{{Name: "severity", Type: TypeEnum}})
if err == nil {
t.Fatal("expected error for enum without values")
}
}
func TestClearCustomFields(t *testing.T) {
t.Cleanup(func() { ClearCustomFields() })
err := RegisterCustomFields([]FieldDef{{Name: "notes", Type: TypeString}})
if err != nil {
t.Fatalf("register: %v", err)
}
if _, ok := Field("notes"); !ok {
t.Fatal("field not found after register")
}
ClearCustomFields()
if _, ok := Field("notes"); ok {
t.Error("field still found after clear")
}
// Fields() should return only built-ins
if len(Fields()) != 15 {
t.Errorf("Fields() = %d, want 15 after clear", len(Fields()))
}
}
func TestRegisterCustomFields_DefensiveCopy(t *testing.T) {
t.Cleanup(func() { ClearCustomFields() })
inputVals := []string{"low", "high"}
defs := []FieldDef{{Name: "severity", Type: TypeEnum, AllowedValues: inputVals}}
if err := RegisterCustomFields(defs); err != nil {
t.Fatalf("register: %v", err)
}
// mutate the input slice — should not affect registry
inputVals[0] = "MUTATED"
defs[0].Name = "MUTATED"
f, ok := Field("severity")
if !ok {
t.Fatal("field not found")
}
if f.AllowedValues[0] != "low" {
t.Errorf("input mutation leaked: AllowedValues[0] = %q, want %q", f.AllowedValues[0], "low")
}
// mutate the returned AllowedValues — should not affect registry
f.AllowedValues[0] = "MUTATED"
f2, _ := Field("severity")
if f2.AllowedValues[0] != "low" {
t.Errorf("returned mutation leaked: AllowedValues[0] = %q, want %q", f2.AllowedValues[0], "low")
}
}