diff --git a/docs/plans/prd-filename-generator-2026-06-01.md b/docs/plans/prd-filename-generator-2026-06-01.md new file mode 100644 index 0000000..7f4187e --- /dev/null +++ b/docs/plans/prd-filename-generator-2026-06-01.md @@ -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): **` `**, 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 = ` ./. ./. `, 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 = ` ().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 (` ()`) 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---.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 ``. **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 ``. +- Per Q4, the default gains a `keyword` segment so non-project drafts read **` `** 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 ` 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 ` 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 ` ` (or ` 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 1–2 are the tracer bullet (engine proven on shipped behaviour + the gap closed). 3–5 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: ` `.** +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). 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") + } + }) + } +}