tiki/workflow/fields.go
2026-04-15 18:32:55 -04:00

211 lines
6.7 KiB
Go

package workflow
import (
"fmt"
"regexp"
"strings"
"sync"
"github.com/boolean-maybe/tiki/ruki/keyword"
)
// ValueType identifies the semantic type of a task field.
type ValueType int
const (
TypeString ValueType = iota
TypeInt // numeric (priority, points)
TypeDate // midnight-UTC date (e.g. due)
TypeTimestamp // full timestamp (e.g. createdAt, updatedAt)
TypeDuration // reserved for future use
TypeBool // reserved for future use
TypeID // task identifier
TypeRef // reference to another task ID
TypeRecurrence // structured cron-based recurrence pattern
TypeListString // []string (e.g. tags)
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
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.
var fieldCatalog = []FieldDef{
{Name: "id", Type: TypeID},
{Name: "title", Type: TypeString},
{Name: "description", Type: TypeString},
{Name: "status", Type: TypeStatus},
{Name: "type", Type: TypeTaskType},
{Name: "tags", Type: TypeListString},
{Name: "dependsOn", Type: TypeListRef},
{Name: "due", Type: TypeDate},
{Name: "recurrence", Type: TypeRecurrence},
{Name: "assignee", Type: TypeString},
{Name: "priority", Type: TypeInt},
{Name: "points", Type: TypeInt},
{Name: "createdBy", Type: TypeString},
{Name: "createdAt", Type: TypeTimestamp},
{Name: "updatedAt", Type: TypeTimestamp},
}
// pre-built lookup for Field()
var fieldByName map[string]FieldDef
func init() {
fieldByName = make(map[string]FieldDef, len(fieldCatalog))
for _, f := range fieldCatalog {
if keyword.IsReserved(f.Name) {
panic(fmt.Sprintf("field catalog contains reserved keyword: %q", f.Name))
}
fieldByName[f.Name] = f
}
}
// 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) {
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,
// 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
// (built-in + custom). Returns deep copies so callers cannot mutate registry state.
func Fields() []FieldDef {
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
}