/** * uat-plan/validate.ts — Zod parse + project-structure resolution + DB connection. * * Resolves the API project (controllers), the web app (componentRegistry) and the * SQL connection (explicit override, else the generated app's appsettings — the * gitignored appsettings.Local.json that `ss dev` writes wins). A missing web app * degrades (warn) — routes fall back to nav-DB list pages. A missing connection is * fatal: the nav/RBAC skeleton lives in the runtime DB. */ import path from 'node:path'; import { existsSync, statSync } from 'node:fs'; import { findSmartStackStructure } from '../../../lib/detector.js'; import { resolveConnectionString, parseConnectionString, type ParsedConnection, } from '../../../lib/appsettings.js'; import { UatPlanInputSchema, type UatPlanInput } from './types.js'; export interface UatPlanLayout { /** Absolute API project dir (controllers). */ apiDir: string; /** Absolute web app dir, or '' if not located. */ webDir: string; /** Artifact dir, relative to projectRoot (POSIX). */ outDir: string; /** Artifact basename. */ name: string; /** Application code (first path segment). */ application: string; } export interface ValidationResult { valid: boolean; spec: UatPlanInput | null; layout: UatPlanLayout | null; connection: ParsedConnection | null; connectionSource: string | null; errors: string[]; warnings: string[]; } function fail(extra: Partial): ValidationResult { return { valid: false, spec: null, layout: null, connection: null, connectionSource: null, errors: [], warnings: [], ...extra, }; } export async function validate(raw: unknown): Promise { const parsed = UatPlanInputSchema.safeParse(raw); if (!parsed.success) { return fail({ errors: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`) }); } const spec = parsed.data; const warnings: string[] = []; const root = path.resolve(spec.projectPath); if (!existsSync(root)) return fail({ spec, errors: [`projectPath does not exist: ${root}`] }); if (!statSync(root).isDirectory()) return fail({ spec, errors: [`projectPath is not a directory: ${root}`] }); const segments = spec.path .split('/') .map((s) => s.trim()) .filter(Boolean); if (segments.length === 0) return fail({ spec, errors: ['path must name at least an Application segment'] }); const application = segments[0]; const name = spec.name ?? segments[segments.length - 1]; const structure = await findSmartStackStructure(root); const apiDir = structure.api ?? structure.apiExtensions ?? structure.apiCore ?? null; if (!apiDir) { return fail({ spec, errors: ['Could not locate the API project (no *.Api*.csproj). Run /uat against a generated SmartStack project.'], }); } const webDir = structure.web ?? ''; if (!webDir) { warnings.push('Web app not located — componentRegistry views unavailable; routes limited to nav-DB list pages.'); } // Connection: explicit spec override, else resolved from the app's appsettings. let connStr = spec.connectionString ?? null; let connectionSource: string | null = spec.connectionString ? 'spec.connectionString' : null; if (!connStr) { const resolved = resolveConnectionString(apiDir); connStr = resolved.value; connectionSource = resolved.source; } if (!connStr) { return fail({ spec, errors: [ `No SQL connection found (looked under ${apiDir}). Run \`ss dev\` to write appsettings.Local.json, or pass "connectionString" in the spec.`, ], }); } const connection = parseConnectionString(connStr); const outDir = spec.outDir ?? `.application-test/uat/${application}`; return { valid: true, spec, layout: { apiDir, webDir, outDir, name, application }, connection, connectionSource, errors: [], warnings, }; }