tiki/config/statuses.go
2026-04-16 15:35:28 -04:00

280 lines
8.4 KiB
Go

package config
import (
"fmt"
"log/slog"
"os"
"sync"
"github.com/boolean-maybe/tiki/workflow"
"gopkg.in/yaml.v3"
)
// StatusDef is a type alias for workflow.StatusDef.
// Kept for backward compatibility during migration.
type StatusDef = workflow.StatusDef
// StatusRegistry is a type alias for workflow.StatusRegistry.
type StatusRegistry = workflow.StatusRegistry
// NormalizeStatusKey delegates to workflow.NormalizeStatusKey.
func NormalizeStatusKey(key string) string {
return string(workflow.NormalizeStatusKey(key))
}
var (
globalStatusRegistry *workflow.StatusRegistry
globalTypeRegistry *workflow.TypeRegistry
registryMu sync.RWMutex
)
// LoadStatusRegistry reads statuses: and types: from workflow.yaml files.
// Both registries are loaded into locals first, then published atomically
// so no intermediate state exists where one is updated and the other stale.
// Returns an error if no statuses are defined anywhere (no Go fallback).
func LoadStatusRegistry() error {
files := FindRegistryWorkflowFiles()
if len(files) == 0 {
return fmt.Errorf("no workflow.yaml found; statuses must be defined in workflow.yaml")
}
statusReg, statusPath, err := loadStatusRegistryFromFiles(files)
if err != nil {
return err
}
if statusReg == nil {
return fmt.Errorf("no statuses defined in workflow.yaml; add a statuses: section")
}
typeReg, typePath, err := loadTypeRegistryFromFiles(files)
if err != nil {
return err
}
// publish both atomically
registryMu.Lock()
globalStatusRegistry = statusReg
globalTypeRegistry = typeReg
registryMu.Unlock()
slog.Debug("loaded status registry", "file", statusPath, "count", len(statusReg.All()))
slog.Debug("loaded type registry", "file", typePath, "count", len(typeReg.All()))
return nil
}
// loadStatusRegistryFromFiles iterates workflow files and returns the registry
// from the last file that contains a non-empty statuses section.
// Returns a parse error immediately if any file is malformed.
func loadStatusRegistryFromFiles(files []string) (*workflow.StatusRegistry, string, error) {
var lastReg *workflow.StatusRegistry
var lastFile string
for _, path := range files {
reg, err := loadStatusesFromFile(path)
if err != nil {
return nil, "", fmt.Errorf("loading statuses from %s: %w", path, err)
}
if reg != nil {
lastReg = reg
lastFile = path
}
}
return lastReg, lastFile, nil
}
// GetStatusRegistry returns the global StatusRegistry.
// Panics if LoadStatusRegistry() was never called — this is a programming error,
// not a user-facing path.
func GetStatusRegistry() *workflow.StatusRegistry {
registryMu.RLock()
defer registryMu.RUnlock()
if globalStatusRegistry == nil {
panic("config: GetStatusRegistry called before LoadStatusRegistry")
}
return globalStatusRegistry
}
// GetTypeRegistry returns the global TypeRegistry.
// Panics if LoadStatusRegistry() was never called.
func GetTypeRegistry() *workflow.TypeRegistry {
registryMu.RLock()
defer registryMu.RUnlock()
if globalTypeRegistry == nil {
panic("config: GetTypeRegistry called before LoadStatusRegistry")
}
return globalTypeRegistry
}
// MaybeGetTypeRegistry returns the global TypeRegistry if it has been
// initialized, or (nil, false) when LoadStatusRegistry() has not run yet.
func MaybeGetTypeRegistry() (*workflow.TypeRegistry, bool) {
registryMu.RLock()
defer registryMu.RUnlock()
return globalTypeRegistry, globalTypeRegistry != nil
}
// ResetStatusRegistry replaces the global registry with one built from the given defs.
// Also resets types to built-in defaults and 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)
if err != nil {
panic(fmt.Sprintf("ResetStatusRegistry: %v", err))
}
typeReg, err := workflow.NewTypeRegistry(workflow.DefaultTypeDefs())
if err != nil {
panic(fmt.Sprintf("ResetStatusRegistry: type registry: %v", err))
}
registryMu.Lock()
globalStatusRegistry = reg
globalTypeRegistry = typeReg
registryMu.Unlock()
workflow.ClearCustomFields()
registriesLoaded.Store(true)
}
// ResetTypeRegistry replaces the global type registry with one built from the
// given defs, without touching the status registry. Intended for tests that
// need custom type configurations while keeping existing status setup.
func ResetTypeRegistry(defs []workflow.TypeDef) {
reg, err := workflow.NewTypeRegistry(defs)
if err != nil {
panic(fmt.Sprintf("ResetTypeRegistry: %v", err))
}
registryMu.Lock()
globalTypeRegistry = reg
registryMu.Unlock()
}
// 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: statuses ---
// workflowStatusData is the YAML shape we unmarshal to extract just the statuses key.
type workflowStatusData struct {
Statuses []workflow.StatusDef `yaml:"statuses"`
}
func loadStatusesFromFile(path string) (*workflow.StatusRegistry, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
var ws workflowStatusData
if err := yaml.Unmarshal(data, &ws); err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
if len(ws.Statuses) == 0 {
return nil, nil // no statuses in this file, try next
}
return workflow.NewStatusRegistry(ws.Statuses)
}
// --- internal: types ---
// validTypeDefKeys is the set of allowed keys inside a types: entry.
var validTypeDefKeys = map[string]bool{
"key": true, "label": true, "emoji": true,
}
// loadTypesFromFile loads types from a single workflow.yaml.
// Returns (registry, present, error):
// - (nil, false, nil) when the types: key is absent — file does not override
// - (reg, true, nil) when types: is present and valid
// - (nil, true, err) when types: is present but invalid (empty list, bad entries)
func loadTypesFromFile(path string) (*workflow.TypeRegistry, bool, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, false, fmt.Errorf("reading %s: %w", path, err)
}
// first pass: check whether the types key exists at all
var raw map[string]interface{}
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, false, fmt.Errorf("parsing %s: %w", path, err)
}
rawTypes, exists := raw["types"]
if !exists {
return nil, false, nil // absent — no opinion
}
// present: validate the raw structure
typesSlice, ok := rawTypes.([]interface{})
if !ok {
return nil, true, fmt.Errorf("types: must be a list, got %T", rawTypes)
}
if len(typesSlice) == 0 {
return nil, true, fmt.Errorf("types section must define at least one type")
}
// validate each entry for unknown keys and convert to TypeDef
defs := make([]workflow.TypeDef, 0, len(typesSlice))
for i, entry := range typesSlice {
entryMap, ok := entry.(map[string]interface{})
if !ok {
return nil, true, fmt.Errorf("type at index %d: expected mapping, got %T", i, entry)
}
for k := range entryMap {
if !validTypeDefKeys[k] {
return nil, true, fmt.Errorf("type at index %d: unknown key %q (valid keys: key, label, emoji)", i, k)
}
}
var def workflow.TypeDef
keyRaw, _ := entryMap["key"].(string)
def.Key = workflow.TaskType(keyRaw)
def.Label, _ = entryMap["label"].(string)
def.Emoji, _ = entryMap["emoji"].(string)
defs = append(defs, def)
}
reg, err := workflow.NewTypeRegistry(defs)
if err != nil {
return nil, true, err
}
return reg, true, nil
}
// loadTypeRegistryFromFiles iterates workflow files. The last file that has a
// types: key wins. Files without a types: key are skipped (no override).
// If no file defines types:, returns a registry built from DefaultTypeDefs().
func loadTypeRegistryFromFiles(files []string) (*workflow.TypeRegistry, string, error) {
var lastReg *workflow.TypeRegistry
var lastFile string
for _, path := range files {
reg, present, err := loadTypesFromFile(path)
if err != nil {
return nil, "", fmt.Errorf("loading types from %s: %w", path, err)
}
if present {
lastReg = reg
lastFile = path
}
}
if lastReg != nil {
return lastReg, lastFile, nil
}
// no file defined types: — fall back to built-in defaults
reg, err := workflow.NewTypeRegistry(workflow.DefaultTypeDefs())
if err != nil {
return nil, "", fmt.Errorf("building default type registry: %w", err)
}
return reg, "<built-in>", nil
}