// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package forms import ( "fmt" "sort" "sync" ) // The registry (design D7; spec form-library "The registry holds every // form and is checked"). Every FormSpec registers into one package-level // index at construction, the way the screens manifest is the one list the // screen-coverage rule reads. The index is the console's list of forms, // not their home: each form is still declared beside the handler that // serves it. Four consumers read it and nothing enters it that none of // them reads: the invariants test, the route test, the capture-coverage // test, and the design system's registered-forms table. var ( registryMu sync.RWMutex registry = map[string]FormSpec{} triggers = map[string]ActionTrigger{} ) // Register indexes one form. A duplicate name panics at boot, because two // forms answering to one name would make every derived check ambiguous. // Registration is idempotent for an identical re-registration, so a // handler constructed twice in one process (a test set beside the real // one) does not bring the process down. func Register(spec FormSpec) FormSpec { registryMu.Lock() defer registryMu.Unlock() if existing, ok := registry[spec.Name]; ok { if !sameSpec(existing, spec) { panic(fmt.Sprintf("forms: two different forms registered as %q", spec.Name)) } return spec } registry[spec.Name] = spec return spec } // All returns every registered form, ordered by name. func All() []FormSpec { registryMu.RLock() defer registryMu.RUnlock() out := make([]FormSpec, 0, len(registry)) for _, s := range registry { out = append(out, s) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } // Lookup returns one registered form by name. func Lookup(name string) (FormSpec, bool) { registryMu.RLock() defer registryMu.RUnlock() s, ok := registry[name] return s, ok } // sameSpec reports whether two registrations describe the same form // closely enough to be one registration. Names, routes and field names are // the parts every derived check reads. func sameSpec(a, b FormSpec) bool { if a.Kind != b.Kind || a.Family != b.Family || a.Method != b.Method || a.Path != b.Path || a.EditMethod != b.EditMethod || a.EditPath != b.EditPath || len(a.Fields) != len(b.Fields) { return false } for i := range a.Fields { if a.Fields[i].Name != b.Fields[i].Name { return false } } return true } // ActionTrigger is a mutation the console fires from something that is not // a form: a row's Revoke button, a panel's Sync control, a sortable // table's reorder. These are the other half of the route-level // template-versus-handler check (findings FA-6, FA-7, FA-9): the route // test asserts every registered mutation route is either a form's path or // one of these, so a handler cannot grow a route nothing renders and no // declaration names. type ActionTrigger struct { // Label is what the control reads, for the test's failure message and // for a reader looking for the control on the page. Label string // Method and Path are the registered route. Method string Path string // Modal reports whether the shared confirm modal guards the trigger. Modal bool // Note records anything a reader needs that the label does not say // (the trigger is the sortable table itself, for instance). Note string } // Key is the trigger's registry key, "METHOD /path". func (t ActionTrigger) Key() string { return t.Method + " " + t.Path } // RegisterTrigger declares one out-of-form mutation trigger. Two triggers // may share a route (the same mutation fired from two places); the first // registration wins and the second is ignored, so a route is declared once // however many controls fire it. func RegisterTrigger(t ActionTrigger) { registryMu.Lock() defer registryMu.Unlock() if _, ok := triggers[t.Key()]; ok { return } triggers[t.Key()] = t } // ActionTriggers returns every declared out-of-form mutation trigger, // ordered by route. func ActionTriggers() []ActionTrigger { registryMu.RLock() defer registryMu.RUnlock() out := make([]ActionTrigger, 0, len(triggers)) for _, t := range triggers { out = append(out, t) } sort.Slice(out, func(i, j int) bool { return out[i].Key() < out[j].Key() }) return out } // Routes returns every route a registered form submits to, as // "METHOD /path", including the record side's route where it differs. func Routes() map[string]string { registryMu.RLock() defer registryMu.RUnlock() out := map[string]string{} for _, s := range registry { out[s.Method+" "+s.Path] = s.Name if s.EditPath != "" { method := s.EditMethod if method == "" { method = s.Method } out[method+" "+s.EditPath] = s.Name } } return out }