import { z } from "zod"; import type { CTPDocument } from "./schema"; // ─── Sub-schemas ────────────────────────────────────────────────────────────── const TimeSignatureSchema = z.object({ numerator: z.number().int().min(1).max(32), denominator: z .number() .int() .refine((n) => [1, 2, 4, 8, 16, 32].includes(n), { message: "denominator must be a power of 2 (1, 2, 4, 8, 16, or 32)", }), }); const StepSectionSchema = z.object({ label: z.string().min(1).max(64), start_bar: z.number().int().min(1), bpm: z.number().min(20).max(400), time_signature: TimeSignatureSchema, transition: z.literal("step"), }); const RampSectionSchema = z.object({ label: z.string().min(1).max(64), start_bar: z.number().int().min(1), bpm_start: z.number().min(20).max(400), bpm_end: z.number().min(20).max(400), time_signature: TimeSignatureSchema, transition: z.literal("ramp"), }); const CTPSectionSchema = z.discriminatedUnion("transition", [ StepSectionSchema, RampSectionSchema, ]); const CountInSchema = z.object({ enabled: z.boolean(), bars: z.number().int().min(1).max(8), use_first_section_tempo: z.boolean(), }); const CTPMetadataSchema = z.object({ title: z.string().min(1).max(256), artist: z.string().min(1).max(256), mbid: z .string() .uuid() .nullable() .default(null), duration_seconds: z.number().positive(), contributed_by: z.string().min(1).max(64), verified: z.boolean(), created_at: z.string().datetime(), }); // ─── Root schema ────────────────────────────────────────────────────────────── export const CTPDocumentSchema = z .object({ version: z.literal("1.0"), metadata: CTPMetadataSchema, count_in: CountInSchema, sections: z.array(CTPSectionSchema).min(1), }) .superRefine((doc, ctx) => { const sections = doc.sections; // First section must start at bar 1 if (sections[0].start_bar !== 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["sections", 0, "start_bar"], message: "The first section must start at bar 1", }); } // Sections must be sorted by start_bar (strictly ascending) for (let i = 1; i < sections.length; i++) { if (sections[i].start_bar <= sections[i - 1].start_bar) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["sections", i, "start_bar"], message: `Section start_bar must be strictly greater than the previous section's start_bar (got ${sections[i].start_bar} after ${sections[i - 1].start_bar})`, }); } } }); // ─── Exported validator ─────────────────────────────────────────────────────── export type CTPValidationResult = | { success: true; data: CTPDocument } | { success: false; errors: z.ZodError }; /** * Validates an unknown value as a CTPDocument. * Returns a typed result union instead of throwing. */ export function validateCTP(input: unknown): CTPValidationResult { const result = CTPDocumentSchema.safeParse(input); if (result.success) { return { success: true, data: result.data as CTPDocument }; } return { success: false, errors: result.error }; } /** * Validates and throws a descriptive error if the document is invalid. * Useful in scripts/CLI contexts. */ export function parseCTP(input: unknown): CTPDocument { return CTPDocumentSchema.parse(input) as CTPDocument; }