Merge: t-paliad-356 Slice 1 — nomen name-composition engine + fold in #155/354 schemes (byte-equal refactor) + PRD
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

This commit is contained in:
mAi
2026-06-01 11:57:13 +02:00
7 changed files with 989 additions and 80 deletions

View File

@@ -0,0 +1,354 @@
# PRD — Composable Name/Filename Generator Engine
**Task:** t-paliad-355 · **Author:** leibniz (inventor) · **Date:** 2026-06-01
**Status:** DESIGN — awaiting head go/no-go on coder shift
**Builds on:** t-paliad-352 / m/paliad#155 (draft title), t-paliad-354 (export filename, merged `94adeeb`)
**Related:** `docs/plans/prd-docforge-2026-05-29.md` (doc-generation engine — a future naming consumer)
---
## § m's decisions (2026-06-01)
All eight grilling questions answered; every pick matched the inventor recommendation.
**Batch 1 — model:**
- Q1 (Composition model): **Structured segments + string shorthand.** Canonical model is an ordered segment list with per-segment missing-rules; a token-template string is an optional authoring shorthand that compiles to segments.
- Q2 (v1 precedence): **System → Firm → User → per-document.** Mirrors the existing dashboard-layout chain exactly. Project-level deferred to v1.1.
- Q3 (Engine depth): **Reusable engine, wire 3 known consumers.** Real engine now; only draft-title, submission-.docx, and the non-project fix are wired. Other surfaces register as known artifacts but keep current code.
- Q4 (Non-project name): **`<date> <keyword>`**, falling back to `Entwurf N` only when no type context exists.
**Batch 2 — concrete:**
- Q5 (Missing-rule set): **omit + placeholder + literal**, per segment.
- Q6 (Date semantics): **Render-time "today", Europe/Berlin, `YYYY-MM-DD`.**
- Q7 (Settings UX): **Live-preview string field on `/settings`** + clickable `{token}` palette. Missing-rules use defaults (not user-editable in v1).
- Q8 (Artifact scope): **2 submission artifacts (`submission_draft_title`, `submission_docx_filename`) + extensible registry.** docforge-export, data-zip, projection-slug registered as known artifacts but unwired in v1.
These are necessary for a coder shift, **not** sufficient — the head still gates whether/who/when to implement (inventor→coder rule).
---
## §0 Premises (verified against the live system, 2026-06-01)
| # | Premise | How verified |
|---|---------|--------------|
| P1 | Draft title = `<date> <client> ./. <forum> ./. <opponent>`, project-bound only, missing segments dropped-with-separator. | Read `internal/services/submission_autoname.go` (`AutoSubmissionTitle`). |
| P2 | Non-project drafts fall back to `Entwurf N` / `Draft N` counter. | Read `submission_draft_service.go` `newDraftName`/`Create`. |
| P3 | Export filename = `<date> <keyword> (<case | "Az. folgt">).docx`; keyword overridable per-draft via `composer_meta.filename_keyword`. | Read `internal/handlers/submissions.go` `submissionFileName` + `submissionFilenameKeyword`. |
| P4 | Sanitiser `SanitiseSubmissionFileName` folds umlauts, maps `/\:*?<>|``_`, strips `"'`, **preserves spaces/parens**. Lives in `pkg/docforge/docx/dotm.go`, re-exported via `services.SanitiseSubmissionFileName` (`docforge_shims.go`). | Read `pkg/docforge/docx/dotm.go`. |
| P5 | `DashboardLayoutSpec` is a production precedent for a validated jsonb spec: code `FactoryDefaultLayout` → admin `firm_dashboard_default` (db row id=1) → per-user `user_dashboard_layouts`, with `Validate()` (write) + `SanitizeForRead()` (read). | Read `dashboard_layout_spec.go`, `firm_dashboard_default_service.go`. |
| P6 | `users.email_preferences jsonb` (per-user bag) and `projects.metadata jsonb` exist live. No dedicated `user_preferences` table — migration 017 only added the `email_preferences` column. | `information_schema.columns` query on live `paliad` schema. |
| P7 | Draft titles are de-duplicated at create time via `uniqueDraftName` (appends a counter on collision). | Read `newDraftName`. |
**Doc-is-the-bug flags raised:** none. The two shipped behaviours are exactly as the task described; `projects.metadata` exists so a project-level override needs no new column when v1.1 arrives (only a documented sub-key).
---
## §1 The problem
Two one-off naming functions shipped in successive tasks (#155, 354). Each hardcodes: a date format, an ordered set of segments, a separator, and a missing-value policy. m wants to stop re-deriving this per feature — "we will need a filename generator more often later on" — and to expose **defaults / compositions** as a **user (and maybe project) setting**. Plus one immediate gap: non-project drafts get no date-led name.
The design must:
1. Extract a **reusable composition engine** that renders a name from (template, variable-bag, render-target).
2. Reproduce **both shipped schemes byte-for-byte** as seed defaults (no behaviour regression).
3. Add **settings** with a clean precedence chain, built **on** the dashboard-spec pattern (P5), not beside it.
4. Fix the **non-project** gap inside the engine, not as another special case.
---
## §2 The engine
A new package **`pkg/nomen`** (Latin *nomen* = "name"; firm-agnostic, sits beside `pkg/docforge`). Pure, dependency-light, table-testable. No DB, no HTTP — consumers resolve variables and hand them in, exactly as `AutoSubmissionTitle` is pure today.
> **FLAG (coder + m):** package name `nomen` is the inventor pick. Alternatives: `pkg/naming`, `internal/services/namegen`. Pick at implementation; nothing downstream depends on the name.
### 2.1 Core types (interface sketch — not final Go)
```go
package nomen
// Segment is one piece of a composition: a variable reference, the
// separator that precedes it, and what to do when the variable resolves
// empty.
type Segment struct {
Var string // key into the variable catalog, e.g. "date", "keyword"
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
}
type MissingRule struct {
Kind MissingKind // KindOmit | KindPlaceholder | KindLiteral
Value string // placeholder/literal text (e.g. "Az. folgt"); ignored for omit
}
// Composition is the canonical, validated model.
type Composition struct {
Version int // schema version (start at 1)
Segments []Segment
}
// VarResolver yields a variable's value for one render. Returns ("", false)
// when the variable is unavailable in this context (→ Missing rule applies).
type VarResolver func(key string) (string, bool)
// RenderTarget post-processes the assembled string (sanitisation, suffix).
type RenderTarget interface {
Name() string // "title" | "filename"
Transform(assembled string) string
}
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:
1. `val, ok := resolve(seg.Var)`.
2. If `!ok || strings.TrimSpace(val) == ""`, apply `seg.Missing`:
- `KindOmit` → segment contributes nothing (and its `Sep` is suppressed).
- `KindPlaceholder``val = seg.Missing.Value` (treated as present).
- `KindLiteral``val = seg.Missing.Value` (same as placeholder; distinct *intent* in the model — "this is a fixed label", not "this is a stand-in for missing data" — so the settings UI can word them differently and future policy can diverge).
3. If the segment emits, prepend `seg.Sep` **iff at least one segment already emitted** (kills the leading-separator problem the #155 code solves by hand), then wrap with `seg.Wrap`.
4. Concatenate.
5. `target.Transform(assembled)` runs once on the whole string.
**Separator suppression** is the generalisation of #155's "drop segment + its leading separator". **Placeholder** is the generalisation of 354's `(Az. folgt)`.
### 2.3 Render targets
The **same** `Composition` renders to different targets:
| Target | `Transform` | Used by |
|--------|-------------|---------|
| `TitleTarget` | identity (spaces, umlauts, ` ./. ` all valid in a human title) | `submission_draft_title` |
| `FilenameTarget{ext: ".docx"}` | per-segment-aware: applies `services.SanitiseSubmissionFileName` to **variable values** (not the frame — preserve the spaces/parens/wrap), then appends `ext`. | `submission_docx_filename` |
> **Design note — where sanitisation runs.** 354 sanitises *each variable value* but keeps the assembled frame (`<date> <kw> (<case>)`) intact. To preserve that exactly, the `FilenameTarget` is **not** a dumb whole-string transform — the engine sanitises each resolved variable value *before* assembly when the target requests it, and the target only appends the extension at the end. So `RenderTarget` gains one more hook:
```go
type RenderTarget interface {
Name() string
SanitiseValue(v string) string // per-variable; identity for TitleTarget
Finalise(assembled string) string // whole-string; appends ".docx" for filename
}
```
This is the one subtlety that makes the engine faithful to 354. Both shipped schemes drop out of `(Composition, VarResolver, RenderTarget)` with no special-casing.
### 2.4 Variable catalog
A `VarCatalog` is an extensible registry: `key → VarDef{ Label, LabelEN, Description, Group }`. The catalog is **metadata only** (for validation + the settings palette); **values** come from the per-render `VarResolver` the consumer supplies. This keeps the engine pure — a consumer registers which keys it can resolve, the engine validates a composition only references known keys.
v1 catalog (the union of what the two schemes need + obvious near-neighbours):
| key | meaning | resolver source (submission consumer) |
|-----|---------|----------------------------------------|
| `date` | render-time today, Europe/Berlin, `YYYY-MM-DD` | engine-provided default resolver (see §2.5) |
| `keyword` | document/submission type; user-overridable | `composer_meta.filename_keyword` → rule name (lang-aware) → "submission" |
| `case_number` | project Aktenzeichen | `project.CaseNumber` |
| `client` | root-ancestor client name | project-tree walk (existing `autoNameForProject`) |
| `forum` | short forum label (UPC/EPA/LG/…) | `submissionForumShort(pt)` (existing) |
| `opponent` | primary opposing party name | `submissionOpponentName(parties, ourSide)` (existing) |
Registered-but-deferred keys (declared so compositions can reference them, resolvers added when a consumer needs them): `proceeding`, `lang`, `client_matter`, `project_name`, `draft_counter`.
**Extensibility contract:** a new consumer (e.g. docforge export) builds its own `VarCatalog` subset + `VarResolver` and registers an artifact (§4). It never edits the engine.
### 2.5 The `date` resolver
The engine ships a default `date` resolver: `time.Now()``Europe/Berlin``Format("2006-01-02")`. This is the **one** variable the engine resolves itself (both shipped schemes compute it identically), so a consumer that only wants the standard date doesn't re-implement it. A consumer may override `date` in its resolver (e.g. a created-at date) — but v1 does not.
---
## §3 Settings & precedence
### 3.1 Precedence chain (v1)
Resolution order for a given artifact, **first hit wins**:
```
per-document override → user override → firm default → system default
(highest priority) (always present)
```
- **System default** — code-resident, per artifact. The seed `Composition` literals (§5). Always exists; nothing can delete it.
- **Firm default** — optional admin-set row, mirrors `firm_dashboard_default` (P5). A firm can mandate a house naming convention. Cleared → reverts to system.
- **User override** — per-user, stored in a jsonb bag keyed by artifact id. Absent key → fall through.
- **Per-document override** — the **already-shipped** `composer_meta.filename_keyword`, generalised to a `composer_meta.name_overrides` map of `{var → value}` (back-compat: `filename_keyword` reads as `name_overrides.keyword` for the filename artifact). This is a *variable-value* override, not a *composition* override — the user is replacing one token's value for one document, not redefining the template.
> **Why per-document is a value override, not a template override:** the shipped "Stichwort" editor lets a lawyer change *what the keyword is* for one draft, not *the shape of the name*. Keeping per-document as value-only avoids giving every draft its own editable template (scope creep) while preserving the shipped UX exactly.
### 3.2 Storage
| Level | Where | Shape |
|-------|-------|-------|
| System | Go code (`nomen` consumer package) | `Composition` literals |
| Firm | **new** `paliad.firm_name_compositions` (id=1 singleton, mirrors `firm_dashboard_default`) | `jsonb`: `{ artifact_id: Composition }` map, validated |
| User | **new column** `paliad.users.name_compositions jsonb NOT NULL DEFAULT '{}'` (mirrors `email_preferences`) | `{ artifact_id: Composition }` map |
| Per-document | **existing** `submission_drafts.composer_meta` | `{ name_overrides: { var: value } }` (supersedes flat `filename_keyword`) |
A `NameCompositionSpec` type gets `Validate()` (write — references-known-vars, known-artifact, ≤ N segments) and `SanitizeForRead()` (read — drop segments referencing dropped vars, clamp version), exactly like `DashboardLayoutSpec`. This is the closest existing analog and the pattern is copy-shaped.
> **Project-level (v1.1, deferred):** when it lands, it slots between user and firm (`per-document → user → project → firm → system`) and stores under a documented `projects.metadata.name_compositions` sub-key — **no migration needed** (P6: column exists). The "project vs user, who wins?" call (Q2) is deferred with it; the v1.1 default is **user wins** (a lawyer's personal convention beats a matter's), but that's a v1.1 decision, flagged here so v1 storage doesn't preclude it.
---
## §4 Artifact registry
An **artifact** is a named thing that gets a name: it binds a default composition, an allowed-variable subset, and a render target.
```go
type Artifact struct {
ID string // "submission_draft_title", "submission_docx_filename"
Label string // for the settings UI
Catalog VarCatalog // which variables are available here
Target RenderTarget // title vs filename
SystemDefault Composition // the seed (§5)
}
```
v1 registry (`internal/services/namegen` — the paliad-side wiring; `pkg/nomen` stays pure):
| Artifact ID | Target | Wired in v1? |
|-------------|--------|--------------|
| `submission_draft_title` | title | **yes** |
| `submission_docx_filename` | filename `.docx` | **yes** |
| `docforge_export` | filename | registered, **unwired** (opts in when docforge ships) |
| `data_zip_export` | filename `.zip` | registered, **unwired** (keeps `ExportFilename` shape) |
| `projection_slug` | slug | registered, **unwired** |
Registering-but-not-wiring means: the artifact ID exists in the catalog so the settings UI *could* list it and a composition *could* be stored, but the consumer still calls its current code path until a follow-up task flips it. No dead behaviour, no speculative resolver code.
> **`data_zip_export` note:** `ExportFilename` (`paliad-export-project-<slug>-<short>-<ts>.zip`) is deliberately machine-shaped (UTC timestamp, uuid disambiguator) — it is **not** a legal title and should **not** inherit the legal-composition defaults. It is registered for *discoverability*, but its eventual opt-in would use a distinct catalog (slug/timestamp/uuid vars), confirming the engine generalises beyond the legal-title model without forcing that model on it.
---
## §5 Seed defaults (the two shipped schemes, as data)
### 5.1 `submission_draft_title` (reproduces `AutoSubmissionTitle`, #155)
```
Segments:
{ Var: "date", Sep: "", Missing: omit }
{ Var: "client", Sep: " ", Missing: omit }
{ Var: "forum", Sep: " ./. ", Missing: omit }
{ Var: "opponent", Sep: " ./. ", Missing: omit }
Target: TitleTarget
```
- All-omit + separator-suppression reproduces "drop empty segment with its leading separator".
- `date` with `Sep: ""` and the others' first-emitted-suppresses-Sep rule yields `2026-05-31 Bayer AG ./. UPC` when opponent is empty — identical to today.
- Non-project draft: `client`/`forum`/`opponent` resolve `("", false)` → all omitted → renders bare `<date>`. **This is the non-project fix** (§6).
### 5.2 `submission_docx_filename` (reproduces `submissionFileName`, 354)
```
Segments:
{ Var: "date", Sep: "", Missing: omit }
{ Var: "keyword", Sep: " ", Missing: literal("submission") }
{ Var: "case_number", Sep: " ", Wrap: {"(", ")"},
Missing: placeholder("Az. folgt") }
Target: FilenameTarget{ext: ".docx"}
```
- `keyword` missing → `literal("submission")` reproduces the `kw == "" → "submission"` fallback.
- `case_number` missing → `placeholder("Az. folgt")`, wrapped in parens → `(Az. folgt)`.
- `FilenameTarget` sanitises each value via `SanitiseSubmissionFileName`, preserves the frame, appends `.docx`. Output identical to 354.
**Faithfulness test (acceptance gate):** golden-file table tests assert the engine's output is byte-equal to the current `AutoSubmissionTitle` / `submissionFileName` across the existing test matrix (with/without opponent, with/without case-number, en/de, umlaut folding). The shipped funcs become thin wrappers over the engine, or are deleted once call-sites move.
---
## §6 The non-project fix
Currently `newDraftName` only calls `autoNameForProject` when `project != nil`; otherwise `nextDraftName``Entwurf N`. Under the engine:
- A non-project draft renders `submission_draft_title` with a resolver where `client/forum/opponent` are all `("", false)` → composition degrades to `<date>`.
- Per Q4, the default gains a `keyword` segment so non-project drafts read **`<date> <keyword>`** where `keyword` = submission/document type if the draft has a `submission_code` that maps to a rule, else falls back.
- **Fallback when no keyword context:** if `keyword` also resolves empty (project-less draft with no `submission_code`/rule), the title degrades to `<date> Entwurf N``Entwurf N` enters as the `keyword` segment's `literal` fallback **with** the existing counter, so uniqueness is preserved via `uniqueDraftName` (P7).
> **FLAG (coder):** confirm whether project-less drafts (t-paliad-243) carry a `submission_code`. If yes, `keyword` derives from the rule like the project path. If no, the `literal("Entwurf N")` fallback is the norm and non-project names read `<date> Entwurf N` — still satisfies "date first there" (m's ask). Resolve in implementation; both paths are handled by the same composition.
The non-project title is the **same** `submission_draft_title` artifact — not a separate composition. Degradation is data-driven, not a code branch. This is the payoff of the engine: the gap closes by *removing* the `project != nil` special-case, not adding another.
---
## §7 Settings UX (v1)
A section on the existing `/settings` page (017 surface):
- **Per artifact** (v1 lists the 2 wired ones): a single-line **token-template string** field, e.g. `{date} {keyword} ({case_number})`.
- A **token palette**: clickable chips (`{date}` `{client}` `{forum}` `{opponent}` `{keyword}` `{case_number}`) insert at cursor. Chips show the localised label (DE primary / EN secondary).
- A **live preview** rendered against a **sample project** (fixed fixture: client "Bayer AG", forum "UPC", opponent "Sandoz", case "UPC_CFI_123/2026", today's date) so the user sees the result instantly — and a second preview line with empties so they see the missing-rule behaviour.
- **Reset to firm/system default** button (mirrors the dashboard "reset layout").
**String ⇄ segments:** the field is the *shorthand* (Q1). A small parser compiles `{var}` tokens + surrounding literals into `Segments` (separators = the literal runs between tokens; `(…)` around a token → `Wrap`). Missing-rules are **not** in the string (Q7) — they come from the system default for that var and are not user-editable in v1. So a user can reorder/drop/re-add tokens and change literals, but can't (yet) flip case-number from placeholder to omit. That's a deliberate v1 boundary; the structured model already supports it, the UI just doesn't expose it.
> Parser edge: a `{token}` the catalog doesn't know → inline validation error ("Unknown variable {foo}"), preview shows nothing, save disabled. Mirrors `DashboardLayoutSpec.Validate` rejecting unknown widget keys.
---
## §8 Slice train
Sliced so a **tracer bullet** ships value before the settings UI exists.
- **Slice 1 — Engine + faithful refactor (no behaviour change).**
`pkg/nomen` (types, render, targets, catalog) + `internal/services/namegen` (artifact registry + the 2 seed compositions + resolvers built from existing `submission_autoname.go` helpers). Re-point `AutoSubmissionTitle` and `submissionFileName` call-sites at the engine. **Acceptance:** §5 golden-file byte-equality; all existing #155/354 tests green unchanged. *No user-visible change — this is the safety net.*
- **Slice 2 — Non-project date-first (§6).**
Remove the `project != nil` special-case in `newDraftName`; non-project drafts render `submission_draft_title`. **Acceptance:** project-less draft gets `<date> <keyword>` (or `<date> Entwurf N` fallback); existing project drafts unchanged. *First user-visible win, m's immediate ask.*
- **Slice 3 — Precedence: system → user (per-document already shipped).**
`users.name_compositions jsonb` column + `NameCompositionSpec` (`Validate`/`SanitizeForRead`) + resolution that prefers a user override over the system default. Generalise `composer_meta.filename_keyword``name_overrides.keyword` (back-compat read). *No UI yet — overrides settable via API/test.*
- **Slice 4 — Settings UX (§7).**
`/settings` token-template field + palette + live preview for the 2 wired artifacts. *User can now customise.*
- **Slice 5 — Firm default.**
`firm_name_compositions` singleton + admin surface, mirroring `firm_dashboard_default`. Slots into precedence below user. *Firm-wide convention.*
Slices 12 are the tracer bullet (engine proven on shipped behaviour + the gap closed). 35 layer settings without re-touching the engine.
---
## §9 Out of scope (this PRD)
- Implementation, migration SQL drafting, Go code.
- Re-litigating #155 / 354 behaviour — they are the seed defaults, reproduced not redesigned.
- **Project-level** compositions (v1.1; storage path reserved in §3.2, precedence call deferred).
- Wiring `docforge_export`, `data_zip_export`, `projection_slug` — registered, not migrated (each is its own follow-up when the surface needs it).
- Naming for non-doc-generation strings across the app.
- User-editable **missing-rules** in the settings UI (model supports it; UI deferred past v1).
---
## §10 Open questions (historical record — resolved in § m's decisions)
1. Composition representation — token-string vs structured-segments vs both. → **Q1: structured + string shorthand.**
2. v1 precedence levels. → **Q2: system → firm → user → per-document.**
3. Generalisation depth (YAGNI vs engine-now). → **Q3: reusable engine, 3 consumers wired.**
4. Non-project default name. → **Q4: `<date> <keyword>`.**
5. Missing-rule policy set. → **Q5: omit + placeholder + literal.**
6. Date semantics. → **Q6: render-time today, Europe/Berlin, `YYYY-MM-DD`.**
7. Settings UX shape. → **Q7: live-preview string field + palette.**
8. Artifact registry scope. → **Q8: 2 submission artifacts + extensible registry.**
**Remaining FLAGs for the coder (not blocking design approval):**
- Package name `pkg/nomen` (vs `naming`/`namegen`) — implementation pick.
- Whether project-less drafts carry a `submission_code` (decides `keyword` source in §6).
- `name_overrides` back-compat read of the existing `filename_keyword` key — confirm the one shipped draft-keyword row migrates cleanly (live round-trip test, like t-paliad-354's).

View File

@@ -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): "<YYYY-MM-DD> <keyword> (<case number>).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 "<date> <kw> (<case>)"
// frame keeps its spaces and brackets — the sanitiser preserves both.
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).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

View File

@@ -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):
//
// <date> <client> ./. <forum> ./. <opponent>
//
// 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
// "<date> <forum> ./. <opponent>" 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):
//
// <date> <keyword> (<case number>).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:
// "<JJJJ-MM-TT> <keyword> (<case number>).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)
}

View File

@@ -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")
}
}

View File

@@ -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 35) 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

228
pkg/nomen/nomen.go Normal file
View 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 34; 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
View 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")
}
})
}
}