mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
Merge pull request #84 from boolean-maybe/feature/custom-fields
custom fields
This commit is contained in:
commit
a226f433d4
36 changed files with 4001 additions and 171 deletions
249
.doc/doki/doc/custom-fields.md
Normal file
249
.doc/doki/doc/custom-fields.md
Normal 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`).
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
175
.doc/doki/doc/ruki/custom-fields-reference.md
Normal file
175
.doc/doki/doc/ruki/custom-fields-reference.md
Normal 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 |
|
||||
|
|
@ -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
272
config/fields.go
Normal 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
225
config/fields_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
4
main.go
4
main.go
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
215
ruki/executor.go
215
ruki/executor.go
|
|
@ -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{}{}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
197
ruki/validate.go
197
ruki/validate.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue