feat(nomen): name-composition engine + fold in the two shipped schemes (t-paliad-356 Slice 1)
Slice 1 of the filename-generator train (PRD 2026-06-01 §8). Pure refactor behind byte-equality — no user-visible change. pkg/nomen: the reusable engine. A Composition (ordered Segments, each with a trailing separator, optional wrap, and an omit/placeholder/literal missing-rule) renders against a VarResolver and a RenderTarget. Targets split into SanitiseValue (per-variable) + Finalise (whole-string + suffix) so a human title and a sanitised filename are two targets of one composition. VarCatalog + Validate guard stored compositions. internal/services/namegen.go: paliad-side wiring — the two seed system- default compositions that reproduce AutoSubmissionTitle (#155) and submissionFileName (354) as DATA, their variable catalogs, the resolvers (built from the existing submission_autoname helpers), and the artifact registry binding artifact -> catalog -> target -> default. Repointed call-sites: AutoSubmissionTitle and handlers.submissionFileName are now thin wrappers rendering through the registry; the assembly logic lives in the engine. Removed the hardcoded title/filename assembly and the handler's Az.-folgt const (now the case_number segment's placeholder). FLAG resolved (separators): the PRD sketched LEADING separators; that can't reproduce #155's client-absent case (date must join forum with a space while forum->opponent stays ' ./. '). Switched to TRAILING separators (owned by the left segment) — the minimal faithful fix. PRD §2.1 annotated. FLAG resolved (back-compat): the shipped composer_meta.filename_keyword override still flows through the engine — live round-trip test green. Acceptance: all existing #155/354 test matrices pass UNCHANGED (the byte-equality gate); new pkg/nomen unit tests cover trailing-sep, the three missing-rules, targets, and Validate; namegen_test validates the seeds against their catalogs. go vet + go test ./... + bun build all clean.
This commit is contained in:
228
pkg/nomen/nomen.go
Normal file
228
pkg/nomen/nomen.go
Normal file
@@ -0,0 +1,228 @@
|
||||
// Package nomen renders human- and machine-facing names from a reusable
|
||||
// composition model (Latin nomen, "name"). It is the engine extracted from
|
||||
// the one-off naming functions that shipped for submission draft titles
|
||||
// (m/paliad#155) and exported .docx filenames (t-paliad-354); see
|
||||
// docs/plans/prd-filename-generator-2026-06-01.md.
|
||||
//
|
||||
// The package is pure: no DB, no HTTP, no filesystem, and no dependency on
|
||||
// the rest of paliad. A consumer supplies a Composition (the template), a
|
||||
// VarResolver (the values for this render), and a RenderTarget (the output
|
||||
// policy — a human title vs a sanitised filename). The same Composition
|
||||
// renders to different targets.
|
||||
//
|
||||
// # Separator semantics (trailing, not leading)
|
||||
//
|
||||
// Each Segment carries a Sep that is the separator emitted AFTER it, and
|
||||
// only when a later segment also emits. So the separator between two
|
||||
// consecutive emitted segments is owned by the LEFT segment. This is what
|
||||
// lets a composition stay byte-faithful when a middle segment drops out:
|
||||
// the draft-title scheme joins the date to the party trio with a space and
|
||||
// the parties to each other with " ./. ", and when the client is absent the
|
||||
// date must still join the forum with a space — which only works if the
|
||||
// space is the date's trailing separator, independent of which identity
|
||||
// segment happens to come next. A leading-separator model can't express
|
||||
// that (the same segment would need two different leading separators
|
||||
// depending on what was omitted before it).
|
||||
package nomen
|
||||
|
||||
import "strings"
|
||||
|
||||
// Version is the current Composition schema version. Stored compositions
|
||||
// (firm/user overrides, once those land) carry it so a future change can be
|
||||
// detected and migrated; the seed system defaults always use this value.
|
||||
const Version = 1
|
||||
|
||||
// MaxSegments is a sanity cap on how many segments a single composition may
|
||||
// have. The wired artifacts use 3–4; the cap exists so a stored override
|
||||
// can't smuggle an unbounded blob through Validate.
|
||||
const MaxSegments = 16
|
||||
|
||||
// MissingKind selects what a segment contributes when its variable resolves
|
||||
// empty or unavailable.
|
||||
type MissingKind int
|
||||
|
||||
const (
|
||||
// KindOmit drops the segment entirely (and suppresses its trailing
|
||||
// separator). Generalises the #155 "drop empty segment with its
|
||||
// separator" rule.
|
||||
KindOmit MissingKind = iota
|
||||
// KindPlaceholder substitutes a stand-in value for missing data, e.g.
|
||||
// "(Az. folgt)" for an as-yet-unknown case number (t-paliad-354).
|
||||
KindPlaceholder
|
||||
// KindLiteral substitutes a fixed label. Functionally identical to a
|
||||
// placeholder today, but kept distinct so the settings UI can word them
|
||||
// differently ("fixed label" vs "stand-in for missing data") and so
|
||||
// future policy can diverge.
|
||||
KindLiteral
|
||||
)
|
||||
|
||||
// MissingRule is a segment's missing-value policy. Value is ignored for
|
||||
// KindOmit.
|
||||
type MissingRule struct {
|
||||
Kind MissingKind
|
||||
Value string
|
||||
}
|
||||
|
||||
// Omit returns a drop-when-empty rule.
|
||||
func Omit() MissingRule { return MissingRule{Kind: KindOmit} }
|
||||
|
||||
// Placeholder returns a substitute-when-empty rule for missing data.
|
||||
func Placeholder(v string) MissingRule { return MissingRule{Kind: KindPlaceholder, Value: v} }
|
||||
|
||||
// Literal returns a substitute-when-empty rule for a fixed label.
|
||||
func Literal(v string) MissingRule { return MissingRule{Kind: KindLiteral, Value: v} }
|
||||
|
||||
// Segment is one piece of a composition.
|
||||
type Segment struct {
|
||||
// Var is the variable key resolved against the catalog/resolver.
|
||||
Var string
|
||||
// Sep is the trailing separator: emitted AFTER this segment iff a later
|
||||
// segment also emits. The last emitted segment's Sep is never used.
|
||||
Sep string
|
||||
// Wrap surrounds the resolved value with fixed literals, e.g.
|
||||
// {"(", ")"} for a bracketed case number. The wrap is part of the frame:
|
||||
// it is NOT passed through the target's value sanitiser.
|
||||
Wrap [2]string
|
||||
// Missing is the policy applied when Var resolves empty/unavailable.
|
||||
Missing MissingRule
|
||||
}
|
||||
|
||||
// Composition is the canonical, validated name template: an ordered list of
|
||||
// segments plus a schema version.
|
||||
type Composition struct {
|
||||
Version int
|
||||
Segments []Segment
|
||||
}
|
||||
|
||||
// VarResolver yields a variable's value for one render. It returns
|
||||
// (value, true) when the variable is available (even if the consumer wants
|
||||
// to force it empty by returning ("", true) — though the engine treats a
|
||||
// blank value as absent regardless), and ("", false) when the variable is
|
||||
// unavailable in this context, in which case the segment's MissingRule
|
||||
// applies.
|
||||
type VarResolver func(key string) (value string, ok bool)
|
||||
|
||||
// RenderTarget post-processes a render. SanitiseValue runs per resolved
|
||||
// variable value (before wrapping/assembly); Finalise runs once on the
|
||||
// fully-assembled string (e.g. to append an extension).
|
||||
type RenderTarget interface {
|
||||
Name() string
|
||||
SanitiseValue(v string) string
|
||||
Finalise(assembled string) string
|
||||
}
|
||||
|
||||
// Render assembles the name. For each segment in order it resolves the
|
||||
// value (applying the MissingRule when empty), sanitises the value via the
|
||||
// target, wraps it, and joins it to the previous emitted segment using that
|
||||
// previous segment's trailing Sep. The assembled string is passed once
|
||||
// through Finalise.
|
||||
func (c Composition) Render(resolve VarResolver, target RenderTarget) string {
|
||||
var b strings.Builder
|
||||
var pendingSep string
|
||||
emitted := false
|
||||
for _, seg := range c.Segments {
|
||||
val, ok := effectiveValue(seg, resolve)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
val = target.SanitiseValue(val)
|
||||
piece := seg.Wrap[0] + val + seg.Wrap[1]
|
||||
if emitted {
|
||||
b.WriteString(pendingSep)
|
||||
}
|
||||
b.WriteString(piece)
|
||||
pendingSep = seg.Sep
|
||||
emitted = true
|
||||
}
|
||||
return target.Finalise(b.String())
|
||||
}
|
||||
|
||||
// effectiveValue resolves a segment to its emitted value, applying the
|
||||
// MissingRule. The second return is false when the segment contributes
|
||||
// nothing (omit, or a placeholder/literal whose value is itself blank).
|
||||
// A resolved value is trimmed; a blank resolved value is treated as absent.
|
||||
func effectiveValue(seg Segment, resolve VarResolver) (string, bool) {
|
||||
val, ok := resolve(seg.Var)
|
||||
val = strings.TrimSpace(val)
|
||||
if ok && val != "" {
|
||||
return val, true
|
||||
}
|
||||
switch seg.Missing.Kind {
|
||||
case KindPlaceholder, KindLiteral:
|
||||
v := strings.TrimSpace(seg.Missing.Value)
|
||||
if v == "" {
|
||||
return "", false
|
||||
}
|
||||
return v, true
|
||||
default: // KindOmit
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// VarDef is a variable's catalog metadata: it drives write-time validation
|
||||
// and the settings palette. Values come from the per-render VarResolver, not
|
||||
// from here — the catalog is metadata only.
|
||||
type VarDef struct {
|
||||
Key string
|
||||
Label string // DE primary
|
||||
LabelEN string
|
||||
Description string
|
||||
Group string
|
||||
}
|
||||
|
||||
// VarCatalog is the set of variables available to an artifact, keyed by Var.
|
||||
type VarCatalog map[string]VarDef
|
||||
|
||||
// Validate enforces the structural invariants on a composition against the
|
||||
// catalog of an artifact. Used on writes (stored firm/user overrides). The
|
||||
// seed system defaults are validated by a unit test so a typo can't ship.
|
||||
func (c Composition) Validate(catalog VarCatalog) error {
|
||||
if c.Version != Version {
|
||||
return &ValidationError{Msg: "unsupported composition version"}
|
||||
}
|
||||
if len(c.Segments) > MaxSegments {
|
||||
return &ValidationError{Msg: "too many segments"}
|
||||
}
|
||||
for _, seg := range c.Segments {
|
||||
if strings.TrimSpace(seg.Var) == "" {
|
||||
return &ValidationError{Msg: "segment has empty variable"}
|
||||
}
|
||||
if _, ok := catalog[seg.Var]; !ok {
|
||||
return &ValidationError{Msg: "unknown variable: " + seg.Var}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidationError is returned by Composition.Validate. It is a distinct type
|
||||
// so consumers can map it to a 400 without string-matching.
|
||||
type ValidationError struct{ Msg string }
|
||||
|
||||
func (e *ValidationError) Error() string { return "nomen: " + e.Msg }
|
||||
|
||||
// FuncTarget is the general RenderTarget: an optional per-value sanitiser
|
||||
// and a fixed suffix appended on finalise. A zero FuncTarget (nil sanitiser,
|
||||
// empty suffix) is an identity target suitable for human titles.
|
||||
type FuncTarget struct {
|
||||
NameVal string
|
||||
Sanitiser func(string) string
|
||||
Suffix string
|
||||
}
|
||||
|
||||
// Name reports the target name (e.g. "title", "filename").
|
||||
func (t FuncTarget) Name() string { return t.NameVal }
|
||||
|
||||
// SanitiseValue applies the per-value sanitiser, or is identity when none.
|
||||
func (t FuncTarget) SanitiseValue(v string) string {
|
||||
if t.Sanitiser == nil {
|
||||
return v
|
||||
}
|
||||
return t.Sanitiser(v)
|
||||
}
|
||||
|
||||
// Finalise appends the target's suffix to the assembled string.
|
||||
func (t FuncTarget) Finalise(assembled string) string { return assembled + t.Suffix }
|
||||
|
||||
// PlainTarget returns an identity target (no sanitisation, no suffix) for
|
||||
// human-facing names such as draft titles.
|
||||
func PlainTarget(name string) RenderTarget { return FuncTarget{NameVal: name} }
|
||||
115
pkg/nomen/nomen_test.go
Normal file
115
pkg/nomen/nomen_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package nomen
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mapResolver builds a VarResolver from a map: a present key (even empty) is
|
||||
// reported present only when its value is non-blank, matching the engine's
|
||||
// blank-is-absent contract.
|
||||
func mapResolver(m map[string]string) VarResolver {
|
||||
return func(key string) (string, bool) {
|
||||
v, ok := m[key]
|
||||
return v, ok
|
||||
}
|
||||
}
|
||||
|
||||
// upperSanitiser is a stand-in per-value transform used to prove SanitiseValue
|
||||
// runs on values but not on separators or wraps.
|
||||
func upperSanitiser(s string) string { return strings.ToUpper(s) }
|
||||
|
||||
func TestRender_TrailingSeparators(t *testing.T) {
|
||||
// date joins with " ", parties join with " ./. " — the draft-title shape.
|
||||
comp := Composition{Version: Version, Segments: []Segment{
|
||||
{Var: "date", Sep: " ", Missing: Omit()},
|
||||
{Var: "client", Sep: " ./. ", Missing: Omit()},
|
||||
{Var: "forum", Sep: " ./. ", Missing: Omit()},
|
||||
{Var: "opponent", Sep: "", Missing: Omit()},
|
||||
}}
|
||||
cases := []struct {
|
||||
name string
|
||||
vars map[string]string
|
||||
want string
|
||||
}{
|
||||
{"all present", map[string]string{"date": "2026-05-31", "client": "Bayer AG", "forum": "UPC", "opponent": "Novartis"}, "2026-05-31 Bayer AG ./. UPC ./. Novartis"},
|
||||
{"client absent — date joins forum with a space", map[string]string{"date": "2026-05-31", "forum": "UPC", "opponent": "Novartis"}, "2026-05-31 UPC ./. Novartis"},
|
||||
{"only opponent absent", map[string]string{"date": "2026-05-31", "client": "Bayer AG", "forum": "UPC"}, "2026-05-31 Bayer AG ./. UPC"},
|
||||
{"forum absent", map[string]string{"date": "2026-05-31", "client": "Bayer AG", "opponent": "Acme"}, "2026-05-31 Bayer AG ./. Acme"},
|
||||
{"date only", map[string]string{"date": "2026-05-31"}, "2026-05-31"},
|
||||
{"blank value treated as absent", map[string]string{"date": "2026-05-31", "client": " ", "forum": "UPC"}, "2026-05-31 UPC"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := comp.Render(mapResolver(c.vars), PlainTarget("title"))
|
||||
if got != c.want {
|
||||
t.Errorf("Render = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_MissingRulesAndTargets(t *testing.T) {
|
||||
// The filename shape: keyword literal fallback, case placeholder + wrap,
|
||||
// a sanitiser + suffix target.
|
||||
comp := Composition{Version: Version, Segments: []Segment{
|
||||
{Var: "date", Sep: " ", Missing: Omit()},
|
||||
{Var: "keyword", Sep: " ", Missing: Literal("submission")},
|
||||
{Var: "case_number", Sep: "", Wrap: [2]string{"(", ")"}, Missing: Placeholder("Az. folgt")},
|
||||
}}
|
||||
target := FuncTarget{NameVal: "filename", Sanitiser: upperSanitiser, Suffix: ".docx"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
vars map[string]string
|
||||
want string
|
||||
}{
|
||||
{"all present — value sanitised, frame preserved", map[string]string{"date": "2026-05-31", "keyword": "Replik", "case_number": "x/y"}, "2026-05-31 REPLIK (X/Y).docx"},
|
||||
{"keyword empty → literal fallback (also sanitised)", map[string]string{"date": "2026-05-31", "case_number": "abc"}, "2026-05-31 SUBMISSION (ABC).docx"},
|
||||
{"case empty → placeholder, wrapped", map[string]string{"date": "2026-05-31", "keyword": "Replik"}, "2026-05-31 REPLIK (AZ. FOLGT).docx"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := comp.Render(mapResolver(c.vars), target)
|
||||
if got != c.want {
|
||||
t.Errorf("Render = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_EmptyPlaceholderOmits(t *testing.T) {
|
||||
// A placeholder/literal whose value is itself blank contributes nothing
|
||||
// (and suppresses its trailing separator).
|
||||
comp := Composition{Version: Version, Segments: []Segment{
|
||||
{Var: "a", Sep: "-", Missing: Placeholder(" ")},
|
||||
{Var: "b", Sep: "", Missing: Omit()},
|
||||
}}
|
||||
got := comp.Render(mapResolver(map[string]string{"b": "tail"}), PlainTarget("x"))
|
||||
if got != "tail" {
|
||||
t.Errorf("Render = %q, want %q", got, "tail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
cat := VarCatalog{"date": {Key: "date"}, "client": {Key: "client"}}
|
||||
ok := Composition{Version: Version, Segments: []Segment{{Var: "date"}, {Var: "client"}}}
|
||||
if err := ok.Validate(cat); err != nil {
|
||||
t.Fatalf("valid composition rejected: %v", err)
|
||||
}
|
||||
bad := []struct {
|
||||
name string
|
||||
comp Composition
|
||||
}{
|
||||
{"wrong version", Composition{Version: 0, Segments: []Segment{{Var: "date"}}}},
|
||||
{"unknown var", Composition{Version: Version, Segments: []Segment{{Var: "nope"}}}},
|
||||
{"empty var", Composition{Version: Version, Segments: []Segment{{Var: " "}}}},
|
||||
}
|
||||
for _, c := range bad {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if err := c.comp.Validate(cat); err == nil {
|
||||
t.Errorf("expected validation error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user