diff --git a/docs/plans/prd-filename-generator-2026-06-01.md b/docs/plans/prd-filename-generator-2026-06-01.md index ddfc525..7f4187e 100644 --- a/docs/plans/prd-filename-generator-2026-06-01.md +++ b/docs/plans/prd-filename-generator-2026-06-01.md @@ -71,9 +71,9 @@ package nomen // empty. type Segment struct { Var string // key into the variable catalog, e.g. "date", "keyword" - Sep string // literal separator emitted BEFORE this segment when both - // it and the previous emitted segment are non-empty. - // First emitted segment never emits its Sep. + Sep string // TRAILING separator: emitted AFTER this segment iff a + // later segment also emits. The last emitted segment's + // Sep is never used. (See Slice-1 note below.) Wrap [2]string // optional surrounding literals, e.g. {"(", ")"} for case-no. Missing MissingRule // omit | placeholder | literal } @@ -103,6 +103,22 @@ func (c Composition) Render(resolve VarResolver, target RenderTarget) string func (c Composition) Validate(catalog VarCatalog) error ``` +> **Implementation note (Slice 1, 2026-06-01 — `Sep` is trailing, not leading).** +> This PRD originally sketched `Sep` as the separator emitted *before* a +> segment. During Slice 1 that model proved unable to reproduce #155 +> byte-for-byte: the existing test `"no client — client segment omitted"` +> requires `2026-05-31 UPC ./. Novartis Pharma` — the date must join the +> *forum* with a single space when the client is absent, while the +> forum-to-opponent join stays ` ./. `. A separator owned by the right-hand +> segment would need two different values for the same segment depending on +> what was omitted before it. Making the separator **trailing** (owned by +> the left-hand segment) is the minimal faithful fix: the date's trailing +> ` ` is used whenever any identity segment follows, and each party's +> trailing ` ./. ` is used whenever another party follows. All shipped +> #155/354 tests pass unchanged. Implemented in `pkg/nomen/nomen.go`; the +> realised `RenderTarget` also splits `Transform` into `SanitiseValue` +> (per-variable) + `Finalise` (whole-string + suffix) per §2.3. + ### 2.2 Render algorithm (reproduces both shipped schemes) For each segment, in order: diff --git a/internal/handlers/submissions.go b/internal/handlers/submissions.go index 95414d1..44e3365 100644 --- a/internal/handlers/submissions.go +++ b/internal/handlers/submissions.go @@ -357,51 +357,13 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) { } } -// submissionNoCaseNumberPlaceholder fills the bracketed case-number slot -// when the project has no Aktenzeichen yet. Kept as a named const so the -// wording is one-line changeable (m left the exact text open, t-paliad-354). -const submissionNoCaseNumberPlaceholder = "Az. folgt" - // submissionFileName produces the user-facing download name -// (t-paliad-354): " ().docx". -// -// - Date first (Europe/Berlin) so the files sort chronologically. -// - keyword is the user override when set, else the lang-aware rule -// name, else "submission". -// - The case number is always rendered in parentheses; when the project -// has no Aktenzeichen it falls back to submissionNoCaseNumberPlaceholder. -// -// Each segment is run through SanitiseSubmissionFileName (umlaut-folds for -// legacy SMB shares, strips the Windows-reserved set so a case number like -// "UPC_CFI_123/2026" stays safe) while the assembled " ()" -// frame keeps its spaces and brackets — the sanitiser preserves both. +// (t-paliad-354): " ().docx". The scheme +// is now the submission_docx_filename artifact of the pkg/nomen engine; this +// remains a thin wrapper so the call-sites and regression tests stay put. +// See services.RenderSubmissionFilename (internal/services/namegen.go). func submissionFileName(rule *models.DeadlineRule, project *models.Project, lang, keyword string) string { - day := time.Now() - if loc, err := time.LoadLocation("Europe/Berlin"); err == nil { - day = day.In(loc) - } - kw := strings.TrimSpace(keyword) - if kw == "" { - kw = strings.TrimSpace(rule.Name) - if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" { - kw = strings.TrimSpace(rule.NameEN) - } - } - if kw == "" { - kw = "submission" - } - caseNo := "" - if project != nil && project.CaseNumber != nil { - caseNo = strings.TrimSpace(*project.CaseNumber) - } - if caseNo == "" { - caseNo = submissionNoCaseNumberPlaceholder - } - return fmt.Sprintf("%s %s (%s).docx", - day.Format("2006-01-02"), - services.SanitiseSubmissionFileName(kw), - services.SanitiseSubmissionFileName(caseNo), - ) + return services.RenderSubmissionFilename(rule, project, lang, keyword) } // submissionFilenameKeyword pulls the user's filename keyword override diff --git a/internal/services/namegen.go b/internal/services/namegen.go new file mode 100644 index 0000000..e4e6dd2 --- /dev/null +++ b/internal/services/namegen.go @@ -0,0 +1,240 @@ +package services + +// Paliad-side wiring for the pkg/nomen composition engine +// (docs/plans/prd-filename-generator-2026-06-01.md, Slice 1). +// +// pkg/nomen stays pure; this file holds the paliad-specific pieces: +// - the variable catalogs (which variables each artifact exposes), +// - the seed system-default Compositions that reproduce the two shipped +// naming schemes byte-for-byte (#155 draft title, t-paliad-354 .docx +// filename), +// - the per-render VarResolvers built from the existing submission_autoname +// helpers (submissionForumShort / submissionOpponentName / derefString), +// - and the artifact registry binding artifact -> catalog -> target -> +// default. +// +// The two public entry points (AutoSubmissionTitle here-adjacent, and +// RenderSubmissionFilename) render through the registry so the engine is the +// single source of truth. Folding the two schemes in as DATA (compositions) +// rather than code is the whole point: future levels (user/firm overrides, +// non-project degradation) layer on without re-deriving the assembly logic. + +import ( + "strings" + "time" + + "mgit.msbls.de/m/paliad/internal/models" + "mgit.msbls.de/m/paliad/pkg/nomen" +) + +// Artifact identifiers. v1 wires the two submission artifacts; further +// artifacts (docforge export, data-zip, projection slug — PRD §4) register +// alongside their own slice, with their own catalog/resolver, when they opt +// in. They are intentionally NOT registered here as placeholders: an +// artifact with no resolver and no consumer would be dead code. +const ( + ArtifactSubmissionDraftTitle = "submission_draft_title" + ArtifactSubmissionDocxFilename = "submission_docx_filename" +) + +// submissionFilenamePlaceholder fills the bracketed case-number slot when the +// project has no Aktenzeichen yet (t-paliad-354). Kept as a named const so +// the wording stays one-line changeable (m left the exact text open). +const submissionFilenamePlaceholder = "Az. folgt" + +// submissionKeywordFallback is the keyword used when neither a user override +// nor a rule name resolves (t-paliad-354). +const submissionKeywordFallback = "submission" + +// Artifact binds a named output to its variable catalog, render target, and +// system-default composition. The catalog drives validation + the settings +// palette; the default is the seed used when no override exists. +type Artifact struct { + ID string + Label string + LabelEN string + Catalog nomen.VarCatalog + Target nomen.RenderTarget + SystemDefault nomen.Composition +} + +// nameArtifacts is the v1 registry. Lookup via NameArtifact. +var nameArtifacts = map[string]Artifact{ + ArtifactSubmissionDraftTitle: { + ID: ArtifactSubmissionDraftTitle, + Label: "Entwurfstitel", + LabelEN: "Draft title", + Catalog: submissionTitleCatalog(), + Target: nomen.PlainTarget("title"), + SystemDefault: submissionDraftTitleComposition(), + }, + ArtifactSubmissionDocxFilename: { + ID: ArtifactSubmissionDocxFilename, + Label: "Dateiname (.docx)", + LabelEN: "File name (.docx)", + Catalog: submissionFilenameCatalog(), + Target: nomen.FuncTarget{ + NameVal: "filename", + Sanitiser: SanitiseSubmissionFileName, + Suffix: ".docx", + }, + SystemDefault: submissionDocxFilenameComposition(), + }, +} + +// NameArtifact returns the registered artifact for id, or (zero, false). +func NameArtifact(id string) (Artifact, bool) { + a, ok := nameArtifacts[id] + return a, ok +} + +// --------------------------------------------------------------------------- +// Seed compositions (the two shipped schemes, as data — PRD §5). +// --------------------------------------------------------------------------- + +// submissionDraftTitleComposition reproduces AutoSubmissionTitle (#155): +// +// ./. ./. +// +// Trailing separators: the date joins the identity block with a space, the +// identity segments join each other with " ./. ". Because separators are +// owned by the left segment, dropping any identity segment (or all of them) +// still yields the byte-exact original — e.g. client-absent renders +// " ./. " with a single space after the date. +func submissionDraftTitleComposition() nomen.Composition { + return nomen.Composition{ + Version: nomen.Version, + Segments: []nomen.Segment{ + {Var: "date", Sep: " ", Missing: nomen.Omit()}, + {Var: "client", Sep: " ./. ", Missing: nomen.Omit()}, + {Var: "forum", Sep: " ./. ", Missing: nomen.Omit()}, + {Var: "opponent", Sep: "", Missing: nomen.Omit()}, + }, + } +} + +// submissionDocxFilenameComposition reproduces submissionFileName (354): +// +// ().docx +// +// keyword falls back to a fixed "submission" literal; the case number is +// always rendered in parentheses, falling back to a placeholder when the +// project has no Aktenzeichen. The .docx suffix and per-value sanitisation +// come from the artifact's FuncTarget, not the composition. +func submissionDocxFilenameComposition() nomen.Composition { + return nomen.Composition{ + Version: nomen.Version, + Segments: []nomen.Segment{ + {Var: "date", Sep: " ", Missing: nomen.Omit()}, + {Var: "keyword", Sep: " ", Missing: nomen.Literal(submissionKeywordFallback)}, + {Var: "case_number", Sep: "", Wrap: [2]string{"(", ")"}, Missing: nomen.Placeholder(submissionFilenamePlaceholder)}, + }, + } +} + +// --------------------------------------------------------------------------- +// Variable catalogs. +// --------------------------------------------------------------------------- + +func submissionTitleCatalog() nomen.VarCatalog { + return nomen.VarCatalog{ + "date": {Key: "date", Label: "Datum", LabelEN: "Date", Group: "common", Description: "Aktuelles Datum (Europe/Berlin), JJJJ-MM-TT"}, + "client": {Key: "client", Label: "Mandant", LabelEN: "Client", Group: "parties", Description: "Name des Mandanten (Wurzel der Akte)"}, + "forum": {Key: "forum", Label: "Forum", LabelEN: "Forum", Group: "proceeding", Description: "Kurzbezeichnung des Forums (UPC, EPA, LG, …)"}, + "opponent": {Key: "opponent", Label: "Gegner", LabelEN: "Opponent", Group: "parties", Description: "Name der Gegenseite"}, + } +} + +func submissionFilenameCatalog() nomen.VarCatalog { + return nomen.VarCatalog{ + "date": {Key: "date", Label: "Datum", LabelEN: "Date", Group: "common", Description: "Aktuelles Datum (Europe/Berlin), JJJJ-MM-TT"}, + "keyword": {Key: "keyword", Label: "Stichwort", LabelEN: "Keyword", Group: "document", Description: "Dokument-/Schriftsatztyp; überschreibbar"}, + "case_number": {Key: "case_number", Label: "Aktenzeichen", LabelEN: "Case number", Group: "proceeding", Description: "Aktenzeichen des Verfahrens"}, + } +} + +// --------------------------------------------------------------------------- +// Resolvers. +// --------------------------------------------------------------------------- + +// nomenDateBerlin formats t as the JJJJ-MM-TT date in Europe/Berlin, +// matching both shipped schemes. A failed zone load leaves t untouched +// (same fallback the original code used). +func nomenDateBerlin(t time.Time) string { + if loc, err := time.LoadLocation("Europe/Berlin"); err == nil { + t = t.In(loc) + } + return t.Format("2006-01-02") +} + +// submissionTitleResolver yields the draft-title variables. now is injected +// (tests pin a fixed instant); the three identity segments resolve from the +// existing helpers and report absence so the composition's Omit rule drops +// them. +func submissionTitleResolver(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType) nomen.VarResolver { + return func(key string) (string, bool) { + switch key { + case "date": + return nomenDateBerlin(now), true + case "client": + c := strings.TrimSpace(clientName) + return c, c != "" + case "forum": + f := submissionForumShort(pt) + return f, f != "" + case "opponent": + ourSide := "" + if project != nil { + ourSide = derefString(project.OurSide) + } + o := submissionOpponentName(parties, ourSide) + return o, o != "" + } + return "", false + } +} + +// submissionFilenameResolver yields the .docx-filename variables. The date is +// render-time "today" (the original used time.Now()); keyword applies the +// override -> lang-aware rule name precedence and reports absence so the +// composition's "submission" literal kicks in; case_number reports absence so +// the "(Az. folgt)" placeholder kicks in. +func submissionFilenameResolver(rule *models.DeadlineRule, project *models.Project, lang, keyword string) nomen.VarResolver { + return func(key string) (string, bool) { + switch key { + case "date": + return nomenDateBerlin(time.Now()), true + case "keyword": + kw := strings.TrimSpace(keyword) + if kw == "" && rule != nil { + kw = strings.TrimSpace(rule.Name) + if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" { + kw = strings.TrimSpace(rule.NameEN) + } + } + return kw, kw != "" + case "case_number": + if project != nil && project.CaseNumber != nil { + c := strings.TrimSpace(*project.CaseNumber) + if c != "" { + return c, true + } + } + return "", false + } + return "", false + } +} + +// RenderSubmissionFilename produces the user-facing download name for a +// generated submission (t-paliad-354), rendered through the nomen engine: +// " ().docx". keyword is the user override +// when set, else the lang-aware rule name, else "submission"; the case number +// falls back to "(Az. folgt)" when the project has no Aktenzeichen. Each +// variable value is sanitised for SMB-safe filenames while the frame (spaces, +// parentheses, .docx) is preserved. +func RenderSubmissionFilename(rule *models.DeadlineRule, project *models.Project, lang, keyword string) string { + art := nameArtifacts[ArtifactSubmissionDocxFilename] + resolve := submissionFilenameResolver(rule, project, lang, keyword) + return art.SystemDefault.Render(resolve, art.Target) +} diff --git a/internal/services/namegen_test.go b/internal/services/namegen_test.go new file mode 100644 index 0000000..b7d42f3 --- /dev/null +++ b/internal/services/namegen_test.go @@ -0,0 +1,34 @@ +package services + +import "testing" + +// TestNameArtifactsValidate guards the seed system-default compositions +// against their own catalogs — a typo'd variable in a seed composition (a key +// the catalog doesn't declare) fails here rather than silently rendering +// nothing in production. +func TestNameArtifactsValidate(t *testing.T) { + for id, art := range nameArtifacts { + if art.ID != id { + t.Errorf("artifact %q has mismatched ID %q", id, art.ID) + } + if art.Target == nil { + t.Errorf("artifact %q has nil target", id) + } + if err := art.SystemDefault.Validate(art.Catalog); err != nil { + t.Errorf("artifact %q system default invalid: %v", id, err) + } + } +} + +// TestNameArtifactLookup covers the registry accessor. +func TestNameArtifactLookup(t *testing.T) { + if _, ok := NameArtifact(ArtifactSubmissionDraftTitle); !ok { + t.Errorf("draft-title artifact not registered") + } + if _, ok := NameArtifact(ArtifactSubmissionDocxFilename); !ok { + t.Errorf("docx-filename artifact not registered") + } + if _, ok := NameArtifact("nonexistent"); ok { + t.Errorf("lookup of unknown artifact returned ok") + } +} diff --git a/internal/services/submission_autoname.go b/internal/services/submission_autoname.go index 562e63e..dc1b27a 100644 --- a/internal/services/submission_autoname.go +++ b/internal/services/submission_autoname.go @@ -17,11 +17,13 @@ package services // a project-less draft never reaches this path at all (it keeps the // "Entwurf N" counter — see SubmissionDraftService.Create). // -// v1.1 customization hook: the template is hardcoded here in v1. When m -// promotes naming to a per-user / per-firm / per-base setting (issue -// #155 Q4), the override string lands as an extra parameter on -// AutoSubmissionTitle (or a small template struct) and the segment -// resolvers below stay as the value source. Nothing else needs to move. +// v1 promotes this scheme into the pkg/nomen composition engine: the +// template lives as the submission_draft_title artifact's system-default +// Composition (see namegen.go, PRD §5.1) and the identity resolvers below +// stay as the value source. AutoSubmissionTitle is now a thin wrapper that +// renders that composition; the assembly logic (separators, missing-segment +// rules) is the engine's. Per-user / per-firm overrides (Slices 3–5) layer +// onto the artifact without touching this file. import ( "strings" @@ -30,10 +32,6 @@ import ( "mgit.msbls.de/m/paliad/internal/models" ) -// submissionTitleSep is the separator between identity segments — -// " ./. " is the German legal convention for "gegen" / "versus". -const submissionTitleSep = " ./. " - // AutoSubmissionTitle assembles the auto-generated draft title from the // resolved identity pieces. Pure and table-testable — every DB hop // happens in the caller (SubmissionDraftService.autoNameForProject). @@ -44,35 +42,13 @@ const submissionTitleSep = " ./. " // the proceeding type both come off the draft's project node, the // parties hang directly off it. // -// The date is always present (formatted in Europe/Berlin to match the -// today.* render vars); the three identity segments are appended only -// when non-empty. +// The date is always present (formatted in Europe/Berlin); the three +// identity segments are appended only when non-empty. Rendered through the +// submission_draft_title artifact (namegen.go). func AutoSubmissionTitle(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType) string { - loc, _ := time.LoadLocation("Europe/Berlin") - if loc != nil { - now = now.In(loc) - } - date := now.Format("2006-01-02") - - segments := make([]string, 0, 3) - if c := strings.TrimSpace(clientName); c != "" { - segments = append(segments, c) - } - if f := submissionForumShort(pt); f != "" { - segments = append(segments, f) - } - ourSide := "" - if project != nil { - ourSide = derefString(project.OurSide) - } - if o := submissionOpponentName(parties, ourSide); o != "" { - segments = append(segments, o) - } - - if len(segments) == 0 { - return date - } - return date + " " + strings.Join(segments, submissionTitleSep) + art := nameArtifacts[ArtifactSubmissionDraftTitle] + resolve := submissionTitleResolver(now, clientName, project, parties, pt) + return art.SystemDefault.Render(resolve, art.Target) } // submissionForumShort maps a proceeding type to the short forum label diff --git a/pkg/nomen/nomen.go b/pkg/nomen/nomen.go new file mode 100644 index 0000000..8f50295 --- /dev/null +++ b/pkg/nomen/nomen.go @@ -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} } diff --git a/pkg/nomen/nomen_test.go b/pkg/nomen/nomen_test.go new file mode 100644 index 0000000..a14a75f --- /dev/null +++ b/pkg/nomen/nomen_test.go @@ -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") + } + }) + } +}