/** * cli:scaffold-time-entry-refs — generate.ts * * Renders the `refs.Entity<…>(…)` declarations and patches the client's * DependencyInjection.cs idempotently: * - markers present → replace ONLY the content between the * `<<< TIME-ENTRY-REFS-DI BEGIN/END >>>` markers (re-runs are clean); * - markers absent → insert a full `services.AddExtensionTimeEntryRefs(…)` * wrapper before the last `return services;`, adding the one required `using`. * * `--discover` (ss upgrade) never registers a dimension — imputation is an opt-in * business decision. It only back-fills the COMMENTED seam wrapper when a project * predates the seam, via `backfillSeamScaffold` (idempotent). * * Entity types are emitted FULLY-QUALIFIED (as provided in the spec) so the host * file needs no extra entity `using`. */ import { BEGIN_MARKER, END_MARKER, TIME_ENTRY_REFS_NAMESPACE, type TimeEntryRefsSpec, type TimeEntryRefEntity } from './types.js'; const IND = ' '; // 12 spaces — markers + first line of each entity (inside the lambda body) const CONT = ' '; // 18 spaces — fluent continuation function escapeString(s: string): string { return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } /** The single-char lambda parameter for an entity, e.g. `Project` → `p` (matches the SDK doc convention). */ export function lambdaParam(entityName: string): string { const simple = entityName.split('.').pop() ?? entityName; return simple.charAt(0).toLowerCase(); } /** Render the `.ActiveWhen(...)` argument: a full lambda passes through, a bare body is wrapped with the entity param. */ function renderActiveWhen(activeWhen: string, param: string): string { return activeWhen.includes('=>') ? activeWhen.trim() : `${param} => ${activeWhen.trim()}`; } /** Render one `refs.Entity<…>(…)….;` statement. */ export function renderEntity(e: TimeEntryRefEntity): string { const p = lambdaParam(e.entityName); const lines: string[] = []; lines.push(`${IND}refs.Entity<${e.entityName}>("${escapeString(e.refType)}", "${escapeString(e.label)}", "${escapeString(e.icon)}")`); lines.push(`${CONT}.WithDisplay(${p} => ${p}.${e.display})`); if (e.subtitle) lines.push(`${CONT}.WithSubtitle(${p} => ${p}.${e.subtitle})`); if (e.activeWhen) lines.push(`${CONT}.ActiveWhen(${renderActiveWhen(e.activeWhen, p)})`); if (e.permission) lines.push(`${CONT}.RequirePermission("${escapeString(e.permission)}")`); if (e.tenantScoped) lines.push(`${CONT}.TenantScoped()`); if (e.order !== 100) lines.push(`${CONT}.Order(${e.order})`); lines[lines.length - 1] += ';'; return lines.join('\n'); } /** The content that lives BETWEEN the markers (markers included). */ export function renderInnerBlock(spec: TimeEntryRefsSpec): string { const body = spec.entities.map(renderEntity).join('\n\n'); return `${IND}${BEGIN_MARKER}\n${body}\n${IND}${END_MARKER}`; } /** The commented placeholder block (markers included) used when back-filling a project that predates the seam. */ export function renderCommentedInnerBlock(): string { return [ `${IND}${BEGIN_MARKER}`, `${IND}// Imputation is opt-in: declare only the dimensions your app should surface in the time-entry picker.`, `${IND}// refs.Entity("myentity", "My entities", "FolderKanban")`, `${IND}// .WithDisplay(x => x.Name)`, `${IND}// .WithSubtitle(x => x.Code)`, `${IND}// .ActiveWhen(x => x.IsActive)`, `${IND}// .RequirePermission("{app}.{module}.{section}.read")`, `${IND}// .TenantScoped();`, `${IND}${END_MARKER}`, ].join('\n'); } /** The full `services.AddExtensionTimeEntryRefs(refs => { … });` wrapper (used when no markers exist yet). */ export function renderFullBlock(spec: TimeEntryRefsSpec): string { return [ ` services.AddExtensionTimeEntryRefs<${spec.contextType}>(refs =>`, ` {`, renderInnerBlock(spec), ` });`, ].join('\n'); } /** The full wrapper with the COMMENTED placeholder (back-fill for `--discover` — never registers a real dimension). */ export function renderCommentedScaffold(contextType: string): string { return [ ` // ── HR time-entry external imputation (client dimensions: projects, mandates…) ──`, ` // Declare each extension entity that may receive time imputation. Nothing is auto-registered:`, ` // imputation is an opt-in business decision, and the tenant admin must also enable the feature`, ` // (HR → Time settings). RefType keys are persisted on core.hr_TimeEntries — keep them stable.`, ` // See references/time-entry-refs.md.`, ` services.AddExtensionTimeEntryRefs<${contextType}>(refs =>`, ` {`, renderCommentedInnerBlock(), ` });`, ].join('\n'); } /** Insert `using {ns};` after the last using directive when it is missing. */ export function ensureUsing(source: string, ns: string): string { const present = new RegExp(`^using\\s+${ns.replace(/\./g, '\\.')}\\s*;`, 'm'); if (present.test(source)) return source; const usingRe = /^using [^\n;]+;[ \t]*$/gm; let last: RegExpExecArray | null = null; let m: RegExpExecArray | null; while ((m = usingRe.exec(source)) !== null) last = m; if (!last) return source; // no usings to anchor to — caller falls back to fully-qualified call if needed const insertAt = last.index + last[0].length; return source.slice(0, insertAt) + `\nusing ${ns};` + source.slice(insertAt); } /** True when the DI source already carries the seam markers. */ export function hasSeamMarkers(source: string): boolean { const beginIdx = source.indexOf(BEGIN_MARKER); const endIdx = source.indexOf(END_MARKER); return beginIdx >= 0 && endIdx >= 0 && endIdx > beginIdx; } /** Insert `block` before the last `return services;` (falling back to the file's final brace). */ function insertBlock(source: string, block: string): string | null { const returnRe = /\n[ \t]*return services;[ \t]*\n/g; let last: RegExpExecArray | null = null; let m: RegExpExecArray | null; while ((m = returnRe.exec(source)) !== null) last = m; if (last) { return source.slice(0, last.index) + '\n' + block + source.slice(last.index); } const lastBrace = source.lastIndexOf('}'); if (lastBrace >= 0) return source.slice(0, lastBrace) + block + '\n' + source.slice(lastBrace); return null; } /** * Idempotently patch the DI source with the REAL registrations (spec mode). * Returns the new source, or `null` when no change was needed (markers already up * to date) or no insertion point was found. */ export function patchTimeEntryRefs(source: string, spec: TimeEntryRefsSpec): string | null { const beginIdx = source.indexOf(BEGIN_MARKER); const endIdx = source.indexOf(END_MARKER); // 1. Markers exist → replace the whole BEGIN..END region (the wrapper already // carries the required `using`, so nothing else to touch). if (beginIdx >= 0 && endIdx >= 0 && endIdx > beginIdx) { const lineStart = source.lastIndexOf('\n', beginIdx) + 1; const lineEnd = source.indexOf('\n', endIdx); const before = source.slice(0, lineStart); const after = lineEnd >= 0 ? source.slice(lineEnd + 1) : ''; const next = before + renderInnerBlock(spec) + (lineEnd >= 0 ? '\n' : '') + after; return next === source ? null : next; } // 2. No markers → insert the full wrapper before the last `return services;`, // ensuring the one required using is present. const src = ensureUsing(source, TIME_ENTRY_REFS_NAMESPACE); return insertBlock(src, renderFullBlock(spec)); } /** * `--discover` back-fill: ensure the COMMENTED seam wrapper exists so `ss upgrade` * teaches an older project the seam WITHOUT ever registering a dimension. Returns * the new source, or `null` when the markers are already present (no-op). */ export function backfillSeamScaffold(source: string, contextType: string): string | null { if (hasSeamMarkers(source)) return null; const src = ensureUsing(source, TIME_ENTRY_REFS_NAMESPACE); return insertBlock(src, renderCommentedScaffold(contextType)); }