diff --git a/package-lock.json b/package-lock.json index ce88116c..94346b97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,8 @@ "graphology": "^0.26.0", "inquirer": "^8.2.6", "openapi-types": "^12.1.3", + "ora": "^8.2.0", + "picocolors": "^1.1.1", "uuid": "^11.1.0", "yaml": "^2.8.0", "zod": "^3.25.76", @@ -6058,12 +6060,14 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -6074,6 +6078,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -6192,6 +6197,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -6546,6 +6552,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", "engines": { "node": ">=0.8" } @@ -6965,6 +6972,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", "dependencies": { "clone": "^1.0.2" }, @@ -8845,6 +8853,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -9415,7 +9435,8 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "BSD-3-Clause" }, "node_modules/ignore": { "version": "5.3.1", @@ -9535,11 +9556,71 @@ "node": ">= 10" } }, + "node_modules/inquirer/node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inquirer/node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inquirer/node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inquirer/node_modules/mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" }, + "node_modules/inquirer/node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inquirer/node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -9772,11 +9853,15 @@ } }, "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-map": { @@ -9982,11 +10067,12 @@ "peer": true }, "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -11217,15 +11303,40 @@ "dev": true }, "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" }, "engines": { - "node": ">=10" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -11380,6 +11491,18 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", @@ -12103,27 +12226,136 @@ "dev": true }, "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/ora/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/p-cancelable": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", @@ -13404,7 +13636,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "engines": { "node": ">=14" }, @@ -13733,6 +13964,18 @@ "node": ">=8" } }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -14681,6 +14924,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", "dependencies": { "defaults": "^1.0.3" } diff --git a/package.json b/package.json index 02a5844e..70b3ac4d 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@oclif/plugin-version": "^2.0.17", "@readme/openapi-parser": "^5.0.1", "chokidar": "^4.0.3", + "picocolors": "^1.1.1", "cosmiconfig": "^9.0.0", "graphology": "^0.26.0", "inquirer": "^8.2.6", diff --git a/src/LoggingInterface.ts b/src/LoggingInterface.ts index b671f17e..678205be 100644 --- a/src/LoggingInterface.ts +++ b/src/LoggingInterface.ts @@ -1,45 +1,364 @@ +/* eslint-disable no-undef, no-console, security/detect-object-injection */ +/** + * Enhanced logging interface with levels, colors, and spinners + */ +import pc from 'picocolors'; + +export type LogLevel = + | 'silent' + | 'error' + | 'warn' + | 'info' + | 'verbose' + | 'debug'; + +const LOG_LEVEL_PRIORITY: Record = { + silent: 0, + error: 1, + warn: 2, + info: 3, + verbose: 4, + debug: 5 +}; + +// Simple spinner implementation using console +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +interface SpinnerState { + text: string; + interval: ReturnType | null; + frameIndex: number; +} + /** * Logging interface for the model generation library */ export interface LoggingInterface { - debug(message?: any, ...optionalParams: any[]): void; - info(message?: any, ...optionalParams: any[]): void; - warn(message?: any, ...optionalParams: any[]): void; - error(message?: any, ...optionalParams: any[]): void; + debug(message?: unknown, ...optionalParams: unknown[]): void; + info(message?: unknown, ...optionalParams: unknown[]): void; + warn(message?: unknown, ...optionalParams: unknown[]): void; + error(message?: unknown, ...optionalParams: unknown[]): void; +} + +/** + * Extended logging interface with additional capabilities + */ +export interface ExtendedLoggingInterface extends LoggingInterface { + // Additional log level + verbose(message?: unknown, ...optionalParams: unknown[]): void; + + // Progress helpers + startSpinner(text: string): void; + updateSpinner(text: string): void; + succeedSpinner(text?: string): void; + failSpinner(text?: string): void; + stopSpinner(): void; + + // Structured output + json(data: unknown): void; + + // Configuration + setLevel(level: LogLevel): void; + setJsonMode(enabled: boolean): void; + setColors(enabled: boolean): void; + + // State queries + getLevel(): LogLevel; + isJsonMode(): boolean; } /** - * Logger class for the model generation library + * Logger class with enhanced capabilities * - * This class acts as a forefront for any external loggers which is why it also implements the interface itself. + * Supports log levels, colors, spinners, and JSON output mode. + * Acts as a forefront for any external loggers. */ -export class LoggerClass implements LoggingInterface { +export class LoggerClass implements ExtendedLoggingInterface { private logger?: LoggingInterface = undefined; + private level: LogLevel = 'info'; + private jsonMode = false; + private colorsEnabled = true; + private spinner: SpinnerState | null = null; + + /** + * Check if a message at the given level should be logged + */ + private shouldLog(messageLevel: LogLevel): boolean { + return ( + LOG_LEVEL_PRIORITY[messageLevel] <= LOG_LEVEL_PRIORITY[this.level] && + !this.jsonMode + ); + } + + /** + * Format a message with optional color + */ + private formatMessage( + message: unknown, + colorFn?: (s: string) => string + ): string { + const msg = String(message); + if (this.colorsEnabled && colorFn) { + return colorFn(msg); + } + return msg; + } + + /** + * Stop spinner before logging to prevent output overlap + */ + private pauseSpinner(): void { + if (this.spinner?.interval) { + clearInterval(this.spinner.interval); + this.spinner.interval = null; + // Clear the current line + if (process.stdout.isTTY) { + process.stdout.clearLine(0); + process.stdout.cursorTo(0); + } + } + } + + /** + * Resume spinner after logging + */ + private resumeSpinner(): void { + if (this.spinner && !this.spinner.interval) { + this.renderSpinner(); + } + } + + /** + * Render the spinner + */ + private renderSpinner(): void { + if (!this.spinner || !process.stdout.isTTY) { + return; + } + + this.spinner.interval = setInterval(() => { + if (!this.spinner) { + return; + } + const frame = this.colorsEnabled + ? pc.cyan(SPINNER_FRAMES[this.spinner.frameIndex]) + : SPINNER_FRAMES[this.spinner.frameIndex]; + process.stdout.clearLine(0); + process.stdout.cursorTo(0); + process.stdout.write(`${frame} ${this.spinner.text}`); + this.spinner.frameIndex = + (this.spinner.frameIndex + 1) % SPINNER_FRAMES.length; + }, 80); + } + + debug(message?: unknown, ...optionalParams: unknown[]): void { + if (!this.shouldLog('debug')) { + return; + } + + this.pauseSpinner(); + const prefix = this.formatMessage('[DEBUG] ', pc.gray); + const formattedMessage = this.formatMessage(message, pc.gray); - debug(message?: any, ...optionalParams: any[]): void { if (this.logger) { - this.logger.debug(message, ...optionalParams); + this.logger.debug(prefix + formattedMessage, ...optionalParams); + } else { + console.debug(prefix + formattedMessage, ...optionalParams); } + this.resumeSpinner(); } - info(message?: any, ...optionalParams: any[]): void { + verbose(message?: unknown, ...optionalParams: unknown[]): void { + if (!this.shouldLog('verbose')) { + return; + } + + this.pauseSpinner(); + const formattedMessage = this.formatMessage(message, pc.dim); + if (this.logger) { - this.logger.info(message, ...optionalParams); + this.logger.info(formattedMessage, ...optionalParams); + } else { + console.log(formattedMessage, ...optionalParams); } + this.resumeSpinner(); } - warn(message?: any, ...optionalParams: any[]): void { + info(message?: unknown, ...optionalParams: unknown[]): void { + if (!this.shouldLog('info')) { + return; + } + + this.pauseSpinner(); + const msg = String(message); + if (this.logger) { - this.logger.warn(message, ...optionalParams); + this.logger.info(msg, ...optionalParams); + } else { + // Use process.stdout.write for better capture by oclif test utilities + const fullMsg = + optionalParams.length > 0 + ? `${msg} ${optionalParams.join(' ')}\n` + : `${msg}\n`; + process.stdout.write(fullMsg); } + this.resumeSpinner(); } - error(message?: any, ...optionalParams: any[]): void { + warn(message?: unknown, ...optionalParams: unknown[]): void { + if (!this.shouldLog('warn')) { + return; + } + + this.pauseSpinner(); + const formattedMessage = this.formatMessage(message, pc.yellow); + if (this.logger) { - this.logger.error(message, ...optionalParams); + this.logger.warn(formattedMessage, ...optionalParams); + } else { + console.warn(formattedMessage, ...optionalParams); + } + this.resumeSpinner(); + } + + error(message?: unknown, ...optionalParams: unknown[]): void { + if (!this.shouldLog('error')) { + return; + } + + this.pauseSpinner(); + const formattedMessage = this.formatMessage(message, pc.red); + + if (this.logger) { + this.logger.error(formattedMessage, ...optionalParams); + } else { + console.error(formattedMessage, ...optionalParams); + } + this.resumeSpinner(); + } + + /** + * Start a spinner with the given text + */ + startSpinner(text: string): void { + if (this.jsonMode) { + return; + } + this.stopSpinner(); + + this.spinner = { + text, + interval: null, + frameIndex: 0 + }; + + if (process.stdout.isTTY) { + this.renderSpinner(); + } else { + // In non-TTY mode, just print the text + console.log(text); + } + } + + /** + * Update the spinner text + */ + updateSpinner(text: string): void { + if (this.spinner) { + this.spinner.text = text; + } + } + + /** + * Stop the spinner with a success message + */ + succeedSpinner(text?: string): void { + const spinnerText = this.spinner?.text; + this.stopSpinner(); + const displayText = text || spinnerText || ''; + if (displayText && this.shouldLog('info')) { + const symbol = this.colorsEnabled ? pc.green('✓') : '[OK]'; + console.log(`${symbol} ${displayText}`); + } + } + + /** + * Stop the spinner with a failure message + */ + failSpinner(text?: string): void { + const spinnerText = this.spinner?.text; + this.stopSpinner(); + const displayText = text || spinnerText || ''; + if (displayText && this.shouldLog('error')) { + const symbol = this.colorsEnabled ? pc.red('✗') : '[FAIL]'; + console.log(`${symbol} ${displayText}`); + } + } + + /** + * Stop the spinner without a message + */ + stopSpinner(): void { + if (this.spinner) { + if (this.spinner.interval) { + clearInterval(this.spinner.interval); + } + if (process.stdout.isTTY) { + process.stdout.clearLine(0); + process.stdout.cursorTo(0); + } + this.spinner = null; } } + /** + * Output structured JSON data + * Only outputs in JSON mode or when explicitly called + */ + json(data: unknown): void { + this.stopSpinner(); + console.log(JSON.stringify(data, null, 2)); + } + + /** + * Set the log level + */ + setLevel(level: LogLevel): void { + this.level = level; + } + + /** + * Enable or disable JSON mode + * In JSON mode, only json() output is shown + */ + setJsonMode(enabled: boolean): void { + this.jsonMode = enabled; + if (enabled) { + this.stopSpinner(); + } + } + + /** + * Enable or disable colored output + */ + setColors(enabled: boolean): void { + this.colorsEnabled = enabled; + } + + /** + * Get the current log level + */ + getLevel(): LogLevel { + return this.level; + } + + /** + * Check if JSON mode is enabled + */ + isJsonMode(): boolean { + return this.jsonMode; + } + /** * Sets the logger to use for the model generation library * @@ -48,6 +367,18 @@ export class LoggerClass implements LoggingInterface { setLogger(logger?: LoggingInterface): void { this.logger = logger; } + + /** + * Reset the logger to default state. + * Useful for testing or when re-initializing the logger. + */ + reset(): void { + this.stopSpinner(); + this.level = 'info'; + this.jsonMode = false; + this.colorsEnabled = true; + this.logger = undefined; + } } export const Logger: LoggerClass = new LoggerClass(); diff --git a/src/codegen/configurations.ts b/src/codegen/configurations.ts index 4771cbfe..0205eab5 100644 --- a/src/codegen/configurations.ts +++ b/src/codegen/configurations.ts @@ -69,10 +69,10 @@ export async function loadConfigFile(filePath?: string): Promise<{ } catch (error: any) { // Check if it's actually a file-not-found error if (error.code === 'ENOENT' || error.message?.includes('ENOENT')) { - throw createConfigNotFoundError(filePath); + throw createConfigNotFoundError({filePath}); } // For other errors (syntax, parse, permission, etc.), wrap in parse error - throw createConfigParseError(filePath, error); + throw createConfigParseError({filePath, originalError: error}); } } else { cosmiConfig = await explorer.search(); @@ -86,7 +86,7 @@ export async function loadConfigFile(filePath?: string): Promise<{ 'codegen.mjs', 'codegen.cjs' ]; - throw createConfigNotFoundError(undefined, searchLocations); + throw createConfigNotFoundError({searchLocations}); } } let codegenConfig; @@ -138,7 +138,7 @@ export function realizeConfiguration( language ); if (!defaultGenerator) { - throw createInvalidPresetError(generator.preset, language); + throw createInvalidPresetError({preset: generator.preset, language}); } const generatorToUse = mergePartialAndDefault( defaultGenerator, @@ -167,7 +167,7 @@ export function realizeConfiguration( // Log each error for debugging errors.forEach((error) => Logger.error(error)); - throw createConfigValidationError(errors); + throw createConfigValidationError({validationErrors: errors}); } const newGenerators = ensureProperGenerators(config); config.generators.push(...(newGenerators as any)); @@ -271,10 +271,10 @@ export async function realizeGeneratorContext( configFile: string | undefined ): Promise { const {config, filePath} = await loadAndRealizeConfigFile(configFile); - Logger.info(`Found configuration was ${JSON.stringify(config)}`); + Logger.debug(`Found configuration: ${JSON.stringify(config, null, 2)}`); const documentPath = path.resolve(path.dirname(filePath), config.inputPath); - Logger.info(`Found document at '${documentPath}'`); - Logger.info(`Found input '${config.inputType}'`); + Logger.verbose(`Document path: ${documentPath}`); + Logger.verbose(`Input type: ${config.inputType}`); const context: RunGeneratorContext = { configuration: config, documentPath, diff --git a/src/codegen/errors.ts b/src/codegen/errors.ts index 344f1a33..3918d6c7 100644 --- a/src/codegen/errors.ts +++ b/src/codegen/errors.ts @@ -1,6 +1,7 @@ /** * Custom error types for better error handling and user-friendly messages */ +import pc from 'picocolors'; export enum ErrorType { CONFIG_NOT_FOUND = 'CONFIG_NOT_FOUND', @@ -11,7 +12,14 @@ export enum ErrorType { MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD', INPUT_DOCUMENT_ERROR = 'INPUT_DOCUMENT_ERROR', GENERATOR_ERROR = 'GENERATOR_ERROR', - UNKNOWN_ERROR = 'UNKNOWN_ERROR' + UNKNOWN_ERROR = 'UNKNOWN_ERROR', + UNSUPPORTED_LANGUAGE = 'UNSUPPORTED_LANGUAGE', + MISSING_INPUT_DOCUMENT = 'MISSING_INPUT_DOCUMENT', + MISSING_PAYLOAD = 'MISSING_PAYLOAD', + MISSING_PARAMETER = 'MISSING_PARAMETER', + CIRCULAR_DEPENDENCY = 'CIRCULAR_DEPENDENCY', + DUPLICATE_GENERATOR_ID = 'DUPLICATE_GENERATOR_ID', + UNSUPPORTED_PRESET_FOR_INPUT = 'UNSUPPORTED_PRESET_FOR_INPUT' } export interface CodegenErrorDetails { @@ -41,16 +49,27 @@ export class CodegenError extends Error { /** * Format the error message for display to users + * @param useColors Whether to use colored output */ - public format(): string { - let output = `\n❌ ${this.message}`; + public format(useColors = true): string { + const c = useColors + ? pc + : { + red: (s: string) => s, + yellow: (s: string) => s, + cyan: (s: string) => s, + dim: (s: string) => s, + bold: (s: string) => s + }; + + let output = `\n${c.red(c.bold('Error:'))} ${this.message}`; if (this.details) { - output += `\n\n📋 Details:\n${this.details}`; + output += `\n\n${c.yellow('Details:')}\n${c.dim(this.details)}`; } if (this.help) { - output += `\n\n💡 How to fix:\n${this.help}`; + output += `\n\n${c.cyan('How to fix:')}\n${this.help}`; } return output; @@ -61,10 +80,11 @@ export class CodegenError extends Error { * Helper functions to create specific error types */ -export function createConfigNotFoundError( - filePath?: string, - searchLocations?: string[] -): CodegenError { +export function createConfigNotFoundError(options?: { + filePath?: string; + searchLocations?: string[]; +}): CodegenError { + const {filePath, searchLocations} = options ?? {}; if (filePath) { return new CodegenError({ type: ErrorType.CONFIG_NOT_FOUND, @@ -83,10 +103,11 @@ export function createConfigNotFoundError( }); } -export function createConfigParseError( - filePath: string, - originalError: Error -): CodegenError { +export function createConfigParseError(options: { + filePath: string; + originalError: Error; +}): CodegenError { + const {filePath, originalError} = options; return new CodegenError({ type: ErrorType.CONFIG_PARSE_ERROR, message: `Failed to parse configuration file: ${filePath}`, @@ -95,10 +116,11 @@ export function createConfigParseError( }); } -export function createInvalidPresetError( - preset: string, - language: string -): CodegenError { +export function createInvalidPresetError(options: { + preset: string; + language: string; +}): CodegenError { + const {preset, language} = options; const validPresets = [ 'payloads', 'parameters', @@ -119,7 +141,10 @@ export function createInvalidPresetError( }); } -export function createInvalidInputTypeError(inputType: string): CodegenError { +export function createInvalidInputTypeError(options: { + inputType: string; +}): CodegenError { + const {inputType} = options; const validTypes = ['asyncapi', 'openapi', 'jsonschema']; const typeList = validTypes.map((t) => ` - ${t}`).join('\n'); @@ -131,10 +156,11 @@ export function createInvalidInputTypeError(inputType: string): CodegenError { }); } -export function createMissingRequiredFieldError( - field: string, - location?: string -): CodegenError { +export function createMissingRequiredFieldError(options: { + field: string; + location?: string; +}): CodegenError { + const {field, location} = options; const locationSuffix = location ? ` at "${location}"` : ''; return new CodegenError({ type: ErrorType.MISSING_REQUIRED_FIELD, @@ -143,9 +169,10 @@ export function createMissingRequiredFieldError( }); } -export function createConfigValidationError( - validationErrors: string[] -): CodegenError { +export function createConfigValidationError(options: { + validationErrors: string[]; +}): CodegenError { + const {validationErrors} = options; return new CodegenError({ type: ErrorType.CONFIG_VALIDATION_ERROR, message: 'Configuration validation failed', @@ -154,22 +181,25 @@ export function createConfigValidationError( }); } -export function createInputDocumentError( - inputPath: string, - originalError: Error -): CodegenError { +export function createInputDocumentError(options: { + inputPath: string; + inputType: string; + errorMessage: string; +}): CodegenError { + const {inputPath, inputType, errorMessage} = options; return new CodegenError({ type: ErrorType.INPUT_DOCUMENT_ERROR, - message: `Failed to load input document: ${inputPath}`, - details: originalError.message, - help: `Check that the input file exists and is a valid ${inputPath.endsWith('.json') ? 'JSON' : 'YAML'} file.\nEnsure the document conforms to the expected specification.` + message: `Failed to load ${inputType} document: ${inputPath}`, + details: errorMessage, + help: `Check that the input file exists and is a valid ${inputPath.endsWith('.json') ? 'JSON' : 'YAML'} file.\nEnsure the document conforms to the ${inputType} specification.` }); } -export function createGeneratorError( - generatorId: string, - originalError: Error -): CodegenError { +export function createGeneratorError(options: { + generatorId: string; + originalError: Error; +}): CodegenError { + const {generatorId, originalError} = options; return new CodegenError({ type: ErrorType.GENERATOR_ERROR, message: `Generator '${generatorId}' failed`, @@ -178,6 +208,111 @@ export function createGeneratorError( }); } +export function createUnsupportedLanguageError(options: { + preset: string; + language: string; +}): CodegenError { + const {preset, language} = options; + return new CodegenError({ + type: ErrorType.UNSUPPORTED_LANGUAGE, + message: `Language '${language}' is not supported for the '${preset}' preset`, + details: `Currently only 'typescript' is supported.`, + help: `Change the 'language' field in your configuration to 'typescript'.\nFor more information: https://the-codegen-project.org/docs/configurations` + }); +} + +export function createMissingInputDocumentError(options: { + expectedType: 'asyncapi' | 'openapi' | 'jsonschema'; + generatorPreset?: string; +}): CodegenError { + const {expectedType, generatorPreset} = options; + const presetContext = generatorPreset + ? ` for the '${generatorPreset}' generator` + : ''; + return new CodegenError({ + type: ErrorType.MISSING_INPUT_DOCUMENT, + message: `Expected ${expectedType} document${presetContext}, but none was provided`, + help: `1. Ensure your configuration has 'inputType: "${expectedType}"'\n2. Verify 'inputPath' points to a valid ${expectedType} document\n3. Check the document exists and is readable\n\nFor more information: https://the-codegen-project.org/docs/inputs/${expectedType}` + }); +} + +export function createMissingPayloadError(options: { + channelOrOperation: string; + protocol?: string; +}): CodegenError { + const {channelOrOperation, protocol} = options; + const protocolContext = protocol ? ` for ${protocol}` : ''; + return new CodegenError({ + type: ErrorType.MISSING_PAYLOAD, + message: `Could not find payload for '${channelOrOperation}'${protocolContext}`, + help: `1. Ensure the 'payloads' generator is configured before 'channels'\n2. Check that your spec defines message payloads for this channel\n3. Verify the channel/operation name matches the spec\n\nFor more information: https://the-codegen-project.org/docs/generators/channels` + }); +} + +export function createMissingParameterError(options: { + channelOrOperation: string; + protocol?: string; +}): CodegenError { + const {channelOrOperation, protocol} = options; + const protocolContext = protocol ? ` for ${protocol}` : ''; + return new CodegenError({ + type: ErrorType.MISSING_PARAMETER, + message: `Could not find parameter for '${channelOrOperation}'${protocolContext}`, + help: `1. Ensure the 'parameters' generator is configured before 'channels'\n2. Check that your spec defines parameters for this channel\n3. Verify the channel/operation name matches the spec\n\nFor more information: https://the-codegen-project.org/docs/generators/channels` + }); +} + +export function createCircularDependencyError(options?: { + generatorIds?: string[]; +}): CodegenError { + const generatorIds = options?.generatorIds; + const idsContext = generatorIds?.length + ? `\nInvolved generators: ${generatorIds.join(', ')}` + : ''; + return new CodegenError({ + type: ErrorType.CIRCULAR_DEPENDENCY, + message: `Circular dependency detected in generator configuration${idsContext}`, + help: `Review the 'dependencies' field in your generators to remove circular references.\nGenerators are executed in dependency order - ensure there's no A->B->A cycle.\n\nFor more information: https://the-codegen-project.org/docs/configurations` + }); +} + +export function createDuplicateGeneratorIdError(options: { + duplicateIds: string[]; +}): CodegenError { + const {duplicateIds} = options; + return new CodegenError({ + type: ErrorType.DUPLICATE_GENERATOR_ID, + message: `Duplicate generator IDs found: ${duplicateIds.join(', ')}`, + help: `Each generator must have a unique 'id'. Either:\n1. Remove duplicate generators\n2. Give each generator a unique 'id' field\n\nFor more information: https://the-codegen-project.org/docs/configurations` + }); +} + +export function createUnsupportedPresetForInputError(options: { + preset: string; + inputType: string; + supportedPresets: string[]; +}): CodegenError { + const {preset, inputType, supportedPresets} = options; + return new CodegenError({ + type: ErrorType.UNSUPPORTED_PRESET_FOR_INPUT, + message: `Preset '${preset}' is not supported with '${inputType}' input`, + details: `Supported presets for ${inputType}: ${supportedPresets.join(', ')}`, + help: `Change your generator to use a supported preset, or change your input type.\n\nFor more information: https://the-codegen-project.org/docs/generators` + }); +} + +export function createMissingDependencyOutputError(options: { + generatorPreset: string; + dependencyName: string; +}): CodegenError { + const {generatorPreset, dependencyName} = options; + return new CodegenError({ + type: ErrorType.GENERATOR_ERROR, + message: `Internal error: Missing dependency output '${dependencyName}' for '${generatorPreset}' generator`, + help: `This is likely a bug. Please report it at: https://github.com/the-codegen-project/cli/issues` + }); +} + /** * Parse Zod validation errors and convert them to user-friendly messages */ @@ -209,58 +344,3 @@ export function parseZodErrors(zodError: any): string[] { return errors; } - -/** - * Detect error type from error message and create appropriate CodegenError - */ -export function enhanceError(error: unknown): CodegenError { - // Already a CodegenError - if (error instanceof CodegenError) { - return error; - } - - // Convert to Error if needed - const err = error instanceof Error ? error : new Error(String(error)); - - // Detect error patterns and create appropriate CodegenError - if (err.message.includes('Cannot find configuration')) { - if (err.message.includes('at path:')) { - const pathMatch = err.message.match(/at path: (.+)/); - const filePath = pathMatch ? pathMatch[1] : undefined; - return createConfigNotFoundError(filePath); - } - const searchLocations = [ - 'codegen.json', - 'codegen.yaml', - 'codegen.yml', - 'codegen.js', - 'codegen.ts', - 'codegen.mjs', - 'codegen.cjs' - ]; - return createConfigNotFoundError(undefined, searchLocations); - } - - if (err.message.includes('Unable to determine default generator')) { - return createInvalidPresetError('unknown', 'typescript'); - } - - if (err.message.includes('Invalid configuration file')) { - // Try to extract validation details - const details = err.message.split('Invalid configuration file; ')[1] || ''; - return new CodegenError({ - type: ErrorType.CONFIG_VALIDATION_ERROR, - message: 'Configuration validation failed', - details, - help: `Review and fix the validation errors in your configuration file.\nFor more information, visit: https://the-codegen-project.org/docs/configurations/` - }); - } - - // Default: wrap in generic error - return new CodegenError({ - type: ErrorType.UNKNOWN_ERROR, - message: err.message, - details: err.stack, - help: `If this error persists, please report it at: https://github.com/the-codegen-project/cli/issues` - }); -} diff --git a/src/codegen/generators/index.ts b/src/codegen/generators/index.ts index c8d1bfae..eb588492 100644 --- a/src/codegen/generators/index.ts +++ b/src/codegen/generators/index.ts @@ -38,14 +38,16 @@ export { CustomGeneratorInternal, CustomContext } from './generic/custom'; -import {RunGeneratorContext} from '../types'; +import {GenerationResult, RunGeneratorContext} from '../types'; import {determineRenderGraph, renderGraph} from '../renderer'; import {realizeGeneratorContext} from '../configurations'; /** * Function that runs the given generator context ensuring the generators are rendered in the correct order. */ -export async function runGenerators(context: RunGeneratorContext) { +export async function runGenerators( + context: RunGeneratorContext +): Promise { const graph = determineRenderGraph(context); return renderGraph(context, graph); } @@ -54,13 +56,14 @@ export async function runGenerators(context: RunGeneratorContext) { * Load the configuration and run the generator * * @param configFileOrContext Either a config file path or a pre-realized RunGeneratorContext + * @returns Generation result with file tracking information */ export async function generateWithConfig( configFileOrContext: string | undefined | RunGeneratorContext -) { +): Promise { const context = typeof configFileOrContext === 'string' || configFileOrContext === undefined ? await realizeGeneratorContext(configFileOrContext) : configFileOrContext; - await runGenerators(context); + return runGenerators(context); } diff --git a/src/codegen/generators/typescript/channels/asyncapi.ts b/src/codegen/generators/typescript/channels/asyncapi.ts index 1dbf8ba8..2f351673 100644 --- a/src/codegen/generators/typescript/channels/asyncapi.ts +++ b/src/codegen/generators/typescript/channels/asyncapi.ts @@ -28,6 +28,10 @@ import { resetHttpCommonTypesState } from './protocols/http'; import {generateWebSocketChannels} from './protocols/websocket'; +import { + createMissingInputDocumentError, + createMissingParameterError +} from '../../../errors'; type Action = 'send' | 'receive' | 'subscribe' | 'publish'; @@ -112,9 +116,10 @@ export async function generateTypeScriptChannelsForAsyncAPI( if (channel.parameters().length > 0) { parameter = parameters.channelModels[channel.id()]; if (parameter === undefined) { - throw new Error( - `Could not find parameter for ${channel.id()} for channel TypeScript generator` - ); + throw createMissingParameterError({ + channelOrOperation: channel.id(), + protocol: 'channels' + }); } } @@ -208,10 +213,16 @@ function validateAsyncapiContext(context: TypeScriptChannelsContext): { } { const {asyncapiDocument, inputType} = context; if (inputType !== 'asyncapi') { - throw new Error('Expected AsyncAPI input, was not given'); + throw createMissingInputDocumentError({ + expectedType: 'asyncapi', + generatorPreset: 'channels' + }); } if (asyncapiDocument === undefined) { - throw new Error('Expected a parsed AsyncAPI document, was not given'); + throw createMissingInputDocumentError({ + expectedType: 'asyncapi', + generatorPreset: 'channels' + }); } return {asyncapiDocument}; } diff --git a/src/codegen/generators/typescript/channels/index.ts b/src/codegen/generators/typescript/channels/index.ts index 805a5638..97a566ab 100644 --- a/src/codegen/generators/typescript/channels/index.ts +++ b/src/codegen/generators/typescript/channels/index.ts @@ -111,6 +111,7 @@ async function finalizeGeneration( const generatedProtocols: string[] = []; const protocolFiles: Record = {}; + const filesWritten: string[] = []; // Write one file per protocol for (const [protocol, functions] of Object.entries(protocolCodeFunctions)) { @@ -135,11 +136,12 @@ async function finalizeGeneration( : ''; const fileContent = `${depsSection}${depsNewline}${functionsSection}${exportSection}\n`; - await writeFile( - path.resolve(context.generator.outputPath, `${protocol}.ts`), - fileContent, - {} + const protocolFilePath = path.resolve( + context.generator.outputPath, + `${protocol}.ts` ); + await writeFile(protocolFilePath, fileContent, {}); + filesWritten.push(protocolFilePath); generatedProtocols.push(protocol); protocolFiles[protocol] = fileContent; @@ -157,11 +159,9 @@ async function finalizeGeneration( indexContent = '// No protocols generated\n'; } - await writeFile( - path.resolve(context.generator.outputPath, 'index.ts'), - indexContent, - {} - ); + const indexFilePath = path.resolve(context.generator.outputPath, 'index.ts'); + await writeFile(indexFilePath, indexContent, {}); + filesWritten.push(indexFilePath); return { parameterRender: parameters, @@ -169,7 +169,8 @@ async function finalizeGeneration( generator: context.generator, renderedFunctions: externalProtocolFunctionInformation, result: indexContent, - protocolFiles + protocolFiles, + filesWritten }; } diff --git a/src/codegen/generators/typescript/channels/openapi.ts b/src/codegen/generators/typescript/channels/openapi.ts index a6430282..a8413ad1 100644 --- a/src/codegen/generators/typescript/channels/openapi.ts +++ b/src/codegen/generators/typescript/channels/openapi.ts @@ -20,6 +20,7 @@ import { } from './protocols/http/fetch'; import {getMessageTypeAndModule} from './utils'; import {pascalCase} from '../utils'; +import {createMissingInputDocumentError} from '../../../errors'; type OpenAPIDocument = | OpenAPIV3.Document @@ -242,10 +243,16 @@ function validateOpenAPIContext(context: TypeScriptChannelsContext): { } { const {openapiDocument, inputType} = context; if (inputType !== 'openapi') { - throw new Error('Expected OpenAPI input, was not given'); + throw createMissingInputDocumentError({ + expectedType: 'openapi', + generatorPreset: 'channels' + }); } if (!openapiDocument) { - throw new Error('Expected a parsed OpenAPI document, was not given'); + throw createMissingInputDocumentError({ + expectedType: 'openapi', + generatorPreset: 'channels' + }); } return {openapiDocument}; } diff --git a/src/codegen/generators/typescript/channels/protocols/amqp/index.ts b/src/codegen/generators/typescript/channels/protocols/amqp/index.ts index 15d6b178..f8a918c6 100644 --- a/src/codegen/generators/typescript/channels/protocols/amqp/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/amqp/index.ts @@ -18,6 +18,7 @@ import {renderSubscribeQueue} from './subscribeQueue'; import {ChannelInterface} from '@asyncapi/parser'; import {SingleFunctionRenderType} from '../../../../../types'; import {ConstrainedObjectModel} from '@asyncapi/modelina'; +import {createMissingPayloadError} from '../../../../../errors'; export {renderPublishExchange, renderPublishQueue, renderSubscribeQueue}; @@ -100,9 +101,10 @@ async function generateForOperations( const payloadId = findOperationId(operation, channel); const payload = payloads.operationModels[payloadId]; if (!payload) { - throw new Error( - `Could not find payload for operation in channel typescript generator` - ); + throw createMissingPayloadError({ + channelOrOperation: payloadId, + protocol: 'AMQP' + }); } const {messageModule, messageType} = getMessageTypeAndModule(payload); @@ -187,7 +189,10 @@ async function generateForChannels( const payload = payloads.channelModels[channel.id()]; if (!payload) { - throw new Error(`Could not find payload for channel typescript generator`); + throw createMissingPayloadError({ + channelOrOperation: channel.id(), + protocol: 'AMQP' + }); } const {messageModule, messageType} = getMessageTypeAndModule(payload); diff --git a/src/codegen/generators/typescript/channels/protocols/eventsource/index.ts b/src/codegen/generators/typescript/channels/protocols/eventsource/index.ts index 3ecfa8bd..1ca31c45 100644 --- a/src/codegen/generators/typescript/channels/protocols/eventsource/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/eventsource/index.ts @@ -17,6 +17,7 @@ import {renderFetch} from './fetch'; import {ChannelInterface} from '@asyncapi/parser'; import {SingleFunctionRenderType} from '../../../../../types'; import {ConstrainedObjectModel} from '@asyncapi/modelina'; +import {createMissingPayloadError} from '../../../../../errors'; export {renderFetch, renderExpress}; @@ -183,7 +184,10 @@ async function generateForChannels( const payload = payloads.channelModels[channel.id()]; if (!payload) { - throw new Error(`Could not find payload for channel typescript generator`); + throw createMissingPayloadError({ + channelOrOperation: channel.id(), + protocol: 'EventSource' + }); } const {messageModule, messageType} = getMessageTypeAndModule(payload); diff --git a/src/codegen/generators/typescript/channels/protocols/kafka/index.ts b/src/codegen/generators/typescript/channels/protocols/kafka/index.ts index d8912124..01f5577b 100644 --- a/src/codegen/generators/typescript/channels/protocols/kafka/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/kafka/index.ts @@ -17,6 +17,7 @@ import {renderSubscribe} from './subscribe'; import {ChannelInterface} from '@asyncapi/parser'; import {SingleFunctionRenderType} from '../../../../../types'; import {ConstrainedObjectModel} from '@asyncapi/modelina'; +import {createMissingPayloadError} from '../../../../../errors'; export {renderPublish, renderSubscribe}; @@ -100,9 +101,10 @@ async function generateForOperations( const payloadId = findOperationId(operation, channel); const payload = payloads.operationModels[payloadId]; if (!payload) { - throw new Error( - `Could not find payload for operation in channel typescript generator for Kafka` - ); + throw createMissingPayloadError({ + channelOrOperation: payloadId, + protocol: 'Kafka' + }); } const {messageModule, messageType} = getMessageTypeAndModule(payload); @@ -176,7 +178,10 @@ async function generateForChannels( const payload = payloads.channelModels[channel.id()]; if (!payload) { - throw new Error(`Could not find payload for channel typescript generator`); + throw createMissingPayloadError({ + channelOrOperation: channel.id(), + protocol: 'Kafka' + }); } const {messageModule, messageType} = getMessageTypeAndModule(payload); diff --git a/src/codegen/generators/typescript/channels/protocols/mqtt/index.ts b/src/codegen/generators/typescript/channels/protocols/mqtt/index.ts index ab129eb6..a3dcecf6 100644 --- a/src/codegen/generators/typescript/channels/protocols/mqtt/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/mqtt/index.ts @@ -17,6 +17,7 @@ import { import {ChannelInterface} from '@asyncapi/parser'; import {SingleFunctionRenderType} from '../../../../../types'; import {ConstrainedObjectModel} from '@asyncapi/modelina'; +import {createMissingPayloadError} from '../../../../../errors'; export {renderPublish, renderSubscribe}; @@ -98,9 +99,10 @@ function generateForOperations( const payloadId = findOperationId(operation, channel); const payload = payloads.operationModels[payloadId]; if (payload === undefined) { - throw new Error( - `Could not find payload for ${payloadId} for channel typescript generator ${JSON.stringify(payloads.operationModels, null, 4)}` - ); + throw createMissingPayloadError({ + channelOrOperation: payloadId, + protocol: 'MQTT' + }); } const {messageModule, messageType} = getMessageTypeAndModule(payload); if (messageType === undefined) { @@ -153,9 +155,10 @@ function generateForChannels( getFunctionTypeMappingFromAsyncAPI(channel) ?? functionTypeMapping; const payload = payloads.channelModels[channel.id()]; if (payload === undefined) { - throw new Error( - `Could not find payload for ${channel.id()} for mqtt channel typescript generator` - ); + throw createMissingPayloadError({ + channelOrOperation: channel.id(), + protocol: 'MQTT' + }); } const {messageModule, messageType} = getMessageTypeAndModule(payload); if (messageType === undefined) { diff --git a/src/codegen/generators/typescript/channels/protocols/nats/index.ts b/src/codegen/generators/typescript/channels/protocols/nats/index.ts index fbc4cdeb..a81e5594 100644 --- a/src/codegen/generators/typescript/channels/protocols/nats/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/nats/index.ts @@ -27,6 +27,7 @@ import {ChannelInterface, OperationInterface} from '@asyncapi/parser'; import {SingleFunctionRenderType} from '../../../../../types'; import {ConstrainedObjectModel} from '@asyncapi/modelina'; import {TypeScriptPayloadRenderType} from '../../../payloads'; +import {createMissingPayloadError} from '../../../../../errors'; export { renderCoreRequest, @@ -134,9 +135,10 @@ async function generateForOperations( const payload = payloads.operationModels[findOperationId(operation, channel)]; if (!payload) { - throw new Error( - `Could not find payload for operation in channel typescript generator for NATS` - ); + throw createMissingPayloadError({ + channelOrOperation: findOperationId(operation, channel), + protocol: 'NATS' + }); } const {messageModule, messageType} = getMessageTypeAndModule(payload); @@ -322,7 +324,10 @@ async function generateForChannels( const payload = payloads.channelModels[channel.id()]; if (!payload) { - throw new Error(`Could not find payload for channel typescript generator`); + throw createMissingPayloadError({ + channelOrOperation: channel.id(), + protocol: 'NATS' + }); } const {messageModule, messageType} = getMessageTypeAndModule(payload); diff --git a/src/codegen/generators/typescript/channels/protocols/websocket/index.ts b/src/codegen/generators/typescript/channels/protocols/websocket/index.ts index 74150c15..504dd9fd 100644 --- a/src/codegen/generators/typescript/channels/protocols/websocket/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/websocket/index.ts @@ -18,6 +18,7 @@ import {renderWebSocketRegister} from './register'; import {ChannelInterface, OperationInterface} from '@asyncapi/parser'; import {SingleFunctionRenderType} from '../../../../../types'; import {ConstrainedObjectModel} from '@asyncapi/modelina'; +import {createMissingPayloadError} from '../../../../../errors'; export { renderWebSocketPublish, @@ -203,7 +204,10 @@ async function generateForChannels( const payload = payloads.channelModels[channel.id()]; if (!payload) { - throw new Error(`Could not find payload for channel typescript generator`); + throw createMissingPayloadError({ + channelOrOperation: channel.id(), + protocol: 'WebSocket' + }); } const {messageModule, messageType} = getMessageTypeAndModule(payload); diff --git a/src/codegen/generators/typescript/channels/types.ts b/src/codegen/generators/typescript/channels/types.ts index 0e9f8800..3f3994ae 100644 --- a/src/codegen/generators/typescript/channels/types.ts +++ b/src/codegen/generators/typescript/channels/types.ts @@ -196,6 +196,10 @@ export interface TypeScriptChannelRenderType { * Useful for testing/snapshot verification of generated protocol code. */ protocolFiles: Record; + /** + * Files written by this generator (absolute paths). + */ + filesWritten: string[]; } export interface RenderRegularParameters { diff --git a/src/codegen/generators/typescript/client/index.ts b/src/codegen/generators/typescript/client/index.ts index 216d7e4d..954ff051 100644 --- a/src/codegen/generators/typescript/client/index.ts +++ b/src/codegen/generators/typescript/client/index.ts @@ -17,6 +17,7 @@ import { zodTypescriptClientGenerator, TypeScriptClientGeneratorInternal } from './types'; +import {createMissingInputDocumentError} from '../../../errors'; export { SupportedProtocols, @@ -33,21 +34,28 @@ export async function generateTypeScriptClient( ): Promise { const {asyncapiDocument, generator, inputType} = context; if (inputType === 'asyncapi' && asyncapiDocument === undefined) { - throw new Error('Expected AsyncAPI input, was not given'); + throw createMissingInputDocumentError({ + expectedType: 'asyncapi', + generatorPreset: 'client' + }); } await mkdir(context.generator.outputPath, {recursive: true}); const renderedProtocols: Record = { nats: '' }; + const filesWritten: string[] = []; + for (const protocol of generator.protocols) { switch (protocol) { case 'nats': { const renderedResult = await generateNatsClient(context); - await writeFile( - path.resolve(context.generator.outputPath, 'NatsClient.ts'), - renderedResult + const filePath = path.resolve( + context.generator.outputPath, + 'NatsClient.ts' ); + await writeFile(filePath, renderedResult); + filesWritten.push(filePath); renderedProtocols[protocol] = renderedResult; break; } @@ -56,7 +64,8 @@ export async function generateTypeScriptClient( } } return { - protocolResult: renderedProtocols + protocolResult: renderedProtocols, + filesWritten }; } diff --git a/src/codegen/generators/typescript/client/protocols/nats.ts b/src/codegen/generators/typescript/client/protocols/nats.ts index 12b7f5b9..c6169580 100644 --- a/src/codegen/generators/typescript/client/protocols/nats.ts +++ b/src/codegen/generators/typescript/client/protocols/nats.ts @@ -18,27 +18,36 @@ import { addPayloadsToDependencies, addPayloadsToExports } from '../../channels/utils'; +import { + createMissingInputDocumentError, + createMissingDependencyOutputError +} from '../../../../errors'; export async function generateNatsClient( context: TypeScriptClientContext ): Promise { const {asyncapiDocument, generator, inputType} = context; if (inputType === 'asyncapi' && asyncapiDocument === undefined) { - throw new Error('Expected AsyncAPI input, was not given'); + throw createMissingInputDocumentError({ + expectedType: 'asyncapi', + generatorPreset: 'client' + }); } if (!context.dependencyOutputs) { - throw new Error( - 'Internal error, could not determine previous rendered outputs that is required for client typescript generator' - ); + throw createMissingDependencyOutputError({ + generatorPreset: 'client', + dependencyName: 'dependencyOutputs' + }); } const channels = context.dependencyOutputs[ generator.channelsGeneratorId ] as TypeScriptChannelRenderType; if (!channels) { - throw new Error( - 'Internal error, could not determine previous rendered channels generator that is required for client TypeScript generator' - ); + throw createMissingDependencyOutputError({ + generatorPreset: 'client', + dependencyName: 'channels' + }); } const renderedFunctions = channels.renderedFunctions; const renderedNatsFunctions = renderedFunctions['nats'] ?? []; diff --git a/src/codegen/generators/typescript/client/types.ts b/src/codegen/generators/typescript/client/types.ts index 591d2af9..b016b942 100644 --- a/src/codegen/generators/typescript/client/types.ts +++ b/src/codegen/generators/typescript/client/types.ts @@ -43,4 +43,8 @@ export interface TypeScriptClientContext extends GenericCodegenContext { export interface TypeScriptClientRenderType { protocolResult: Record; + /** + * Files written by this generator (absolute paths). + */ + filesWritten: string[]; } diff --git a/src/codegen/generators/typescript/headers.ts b/src/codegen/generators/typescript/headers.ts index a1162559..2a6c5eb9 100644 --- a/src/codegen/generators/typescript/headers.ts +++ b/src/codegen/generators/typescript/headers.ts @@ -13,6 +13,7 @@ import { typeScriptDefaultPropertyKeyConstraints } from '@asyncapi/modelina'; import {createValidationPreset} from '../../modelina/presets'; +import {createMissingInputDocumentError} from '../../errors'; export const zodTypescriptHeadersGenerator = z.object({ id: z.string().optional().default('headers-typescript'), @@ -72,7 +73,10 @@ export async function generateTypescriptHeadersCore({ }: { processedData: ProcessedHeadersData; context: TypescriptHeadersContext; -}): Promise> { +}): Promise<{ + channelModels: Record; + filesWritten: string[]; +}> { const {generator} = context; const modelinaGenerator = new TypeScriptFileGenerator({ ...defaultCodegenTypescriptModelinaOptions, @@ -101,6 +105,7 @@ export async function generateTypescriptHeadersCore({ }); const channelModels: Record = {}; + const filesWritten: string[] = []; for (const [channelId, headerData] of Object.entries( processedData.channelHeaders @@ -113,12 +118,19 @@ export async function generateTypescriptHeadersCore({ true ); channelModels[channelId] = models[0]; + + // Track files written + for (const model of models) { + if (model.modelName) { + filesWritten.push(`${generator.outputPath}/${model.modelName}.ts`); + } + } } else { channelModels[channelId] = undefined; } } - return channelModels; + return {channelModels, filesWritten: [...new Set(filesWritten)]}; } // Main generator function that orchestrates input processing and generation @@ -133,13 +145,19 @@ export async function generateTypescriptHeaders( switch (inputType) { case 'asyncapi': if (!asyncapiDocument) { - throw new Error('Expected AsyncAPI input, was not given'); + throw createMissingInputDocumentError({ + expectedType: 'asyncapi', + generatorPreset: 'headers' + }); } processedData = processAsyncAPIHeaders(asyncapiDocument); break; case 'openapi': if (!openapiDocument) { - throw new Error('Expected OpenAPI input, was not given'); + throw createMissingInputDocumentError({ + expectedType: 'openapi', + generatorPreset: 'headers' + }); } processedData = processOpenAPIHeaders(openapiDocument); break; @@ -148,13 +166,14 @@ export async function generateTypescriptHeaders( } // Generate models using processed data - const channelModels = await generateTypescriptHeadersCore({ + const {channelModels, filesWritten} = await generateTypescriptHeadersCore({ processedData, context }); return { channelModels, - generator + generator, + filesWritten }; } diff --git a/src/codegen/generators/typescript/models.ts b/src/codegen/generators/typescript/models.ts index 4e5479c8..31dd3eb5 100644 --- a/src/codegen/generators/typescript/models.ts +++ b/src/codegen/generators/typescript/models.ts @@ -10,6 +10,7 @@ import {z} from 'zod'; import {OpenAPIV2, OpenAPIV3, OpenAPIV3_1} from 'openapi-types'; import {zodTypeScriptOptions, zodTypeScriptPresets} from '../../modelina'; import {JsonSchemaDocument} from '../../inputs/jsonschema'; +import {CodegenError, ErrorType} from '../../errors'; export const zodTypescriptModelsGenerator = z.object({ id: z.string().optional().default('models-typescript'), @@ -63,17 +64,30 @@ export async function generateTypescriptModels( asyncapiDocument ?? openapiDocument ?? jsonSchemaDocument; if (!inputDocument) { - throw new Error('No input document provided for models generation'); + throw new CodegenError({ + type: ErrorType.MISSING_INPUT_DOCUMENT, + message: 'No input document provided for models generation', + help: `Ensure your configuration specifies 'inputPath' pointing to a valid AsyncAPI, OpenAPI, or JSON Schema document.\n\nFor more information: https://the-codegen-project.org/docs/configurations` + }); } - await modelGenerator.generateToFiles( + const models = await modelGenerator.generateToFiles( inputDocument, generator.outputPath, {exportType: 'named'}, true ); + // Track files written + const filesWritten: string[] = []; + for (const model of models) { + if (model.modelName) { + filesWritten.push(`${generator.outputPath}/${model.modelName}.ts`); + } + } + return { - generator + generator, + filesWritten: [...new Set(filesWritten)] }; } diff --git a/src/codegen/generators/typescript/parameters.ts b/src/codegen/generators/typescript/parameters.ts index 3a6e2ee1..77eb5320 100644 --- a/src/codegen/generators/typescript/parameters.ts +++ b/src/codegen/generators/typescript/parameters.ts @@ -1,4 +1,4 @@ -/* eslint-disable security/detect-object-injection */ +/* eslint-disable security/detect-object-injection, sonarjs/cognitive-complexity */ import {OutputModel, TypeScriptFileGenerator} from '@asyncapi/modelina'; import {AsyncAPIDocumentInterface} from '@asyncapi/parser'; import {GenericCodegenContext, ParameterRenderType} from '../../types'; @@ -13,6 +13,7 @@ import { createOpenAPIGenerator, processOpenAPIParameters } from '../../inputs/openapi/generators/parameters'; +import {createMissingInputDocumentError} from '../../errors'; export const zodTypescriptParametersGenerator = z.object({ id: z.string().optional().default('parameters-typescript'), @@ -53,6 +54,7 @@ export async function generateTypescriptParameters( const {asyncapiDocument, openapiDocument, inputType, generator} = context; const channelModels: Record = {}; + const filesWritten: string[] = []; let processedSchemaData: ProcessedParameterSchemaData; let parameterGenerator: TypeScriptFileGenerator; @@ -60,7 +62,10 @@ export async function generateTypescriptParameters( switch (inputType) { case 'asyncapi': { if (!asyncapiDocument) { - throw new Error('Expected AsyncAPI input, was not given'); + throw createMissingInputDocumentError({ + expectedType: 'asyncapi', + generatorPreset: 'parameters' + }); } processedSchemaData = await processAsyncAPIParameters(asyncapiDocument); @@ -69,7 +74,10 @@ export async function generateTypescriptParameters( } case 'openapi': { if (!openapiDocument) { - throw new Error('Expected OpenAPI input, was not given'); + throw createMissingInputDocumentError({ + expectedType: 'openapi', + generatorPreset: 'parameters' + }); } processedSchemaData = processOpenAPIParameters(openapiDocument); @@ -92,6 +100,13 @@ export async function generateTypescriptParameters( true ); channelModels[channelId] = models.length > 0 ? models[0] : undefined; + + // Track files written + for (const model of models) { + if (model.modelName) { + filesWritten.push(`${generator.outputPath}/${model.modelName}.ts`); + } + } } else { channelModels[channelId] = undefined; } @@ -99,6 +114,7 @@ export async function generateTypescriptParameters( return { channelModels, - generator + generator, + filesWritten: [...new Set(filesWritten)] }; } diff --git a/src/codegen/generators/typescript/payloads.ts b/src/codegen/generators/typescript/payloads.ts index 8b5df5f7..0200f650 100644 --- a/src/codegen/generators/typescript/payloads.ts +++ b/src/codegen/generators/typescript/payloads.ts @@ -20,6 +20,7 @@ import { createUnionPreset, createPrimitivesPreset } from '../../modelina/presets'; +import {createMissingInputDocumentError} from '../../errors'; export const zodTypeScriptPayloadGenerator = z.object({ id: z.string().optional().default('payloads-typescript'), @@ -111,7 +112,8 @@ export async function generateTypescriptPayloadsCore( channelModels: processedData.channelModels, operationModels: processedData.operationModels, otherModels: processedData.otherModels, - generator + generator, + filesWritten: [] }; } @@ -169,6 +171,7 @@ export async function generateTypescriptPayloadsCoreFromSchemas({ > = {}; const otherModels: Array<{messageModel: OutputModel; messageType: string}> = []; + const filesWritten: string[] = []; // Generate models for channel payloads for (const [channelId, schemaData] of Object.entries( @@ -193,6 +196,13 @@ export async function generateTypescriptPayloadsCoreFromSchemas({ messageType }; + // Track files written + for (const model of models) { + if (model.modelName) { + filesWritten.push(`${generator.outputPath}/${model.modelName}.ts`); + } + } + // Add any additional models to otherModels for (let i = 1; i < models.length; i++) { const additionalModel = models[i].model; @@ -228,6 +238,13 @@ export async function generateTypescriptPayloadsCoreFromSchemas({ messageType }; + // Track files written + for (const model of models) { + if (model.modelName) { + filesWritten.push(`${generator.outputPath}/${model.modelName}.ts`); + } + } + // Add any additional models to otherModels for (let i = 1; i < models.length; i++) { const additionalModel = models[i].model; @@ -258,6 +275,10 @@ export async function generateTypescriptPayloadsCoreFromSchemas({ messageModel: model, messageType }); + // Track file written + if (model.modelName) { + filesWritten.push(`${generator.outputPath}/${model.modelName}.ts`); + } } } @@ -265,7 +286,8 @@ export async function generateTypescriptPayloadsCoreFromSchemas({ channelModels, operationModels, otherModels, - generator + generator, + filesWritten: [...new Set(filesWritten)] // deduplicate }; } @@ -281,7 +303,10 @@ export async function generateTypescriptPayload( switch (inputType) { case 'asyncapi': { if (!asyncapiDocument) { - throw new Error('Expected AsyncAPI input, was not given'); + throw createMissingInputDocumentError({ + expectedType: 'asyncapi', + generatorPreset: 'payloads' + }); } processedSchemaData = await processAsyncAPIPayloads(asyncapiDocument); @@ -289,7 +314,10 @@ export async function generateTypescriptPayload( } case 'openapi': { if (!openapiDocument) { - throw new Error('Expected OpenAPI input, was not given'); + throw createMissingInputDocumentError({ + expectedType: 'openapi', + generatorPreset: 'payloads' + }); } processedSchemaData = processOpenAPIPayloads(openapiDocument); diff --git a/src/codegen/generators/typescript/types.ts b/src/codegen/generators/typescript/types.ts index 3682a1e8..6fe93e55 100644 --- a/src/codegen/generators/typescript/types.ts +++ b/src/codegen/generators/typescript/types.ts @@ -4,6 +4,7 @@ import {z} from 'zod'; import {OpenAPIV2, OpenAPIV3, OpenAPIV3_1} from 'openapi-types'; import {generateAsyncAPITypes} from '../../inputs/asyncapi/generators/types'; import {generateOpenAPITypes} from '../../inputs/openapi/generators/types'; +import {createMissingInputDocumentError} from '../../errors'; export const zodTypescriptTypesGenerator = z.object({ id: z.string().optional().default('types-typescript'), @@ -42,19 +43,40 @@ export async function generateTypescriptTypes( const {asyncapiDocument, openapiDocument, inputType, generator} = context; let result: string; + let filesWritten: string[] = []; switch (inputType) { case 'asyncapi': if (!asyncapiDocument) { - throw new Error('Expected AsyncAPI input, was not given'); + throw createMissingInputDocumentError({ + expectedType: 'asyncapi', + generatorPreset: 'types' + }); + } + { + const asyncAPIResult = await generateAsyncAPITypes( + asyncapiDocument, + generator + ); + result = asyncAPIResult.result; + filesWritten = asyncAPIResult.filesWritten; } - result = await generateAsyncAPITypes(asyncapiDocument, generator); break; case 'openapi': if (!openapiDocument) { - throw new Error('Expected OpenAPI input, was not given'); + throw createMissingInputDocumentError({ + expectedType: 'openapi', + generatorPreset: 'types' + }); + } + { + const openAPIResult = await generateOpenAPITypes( + openapiDocument, + generator + ); + result = openAPIResult.result; + filesWritten = openAPIResult.filesWritten; } - result = await generateOpenAPITypes(openapiDocument, generator); break; default: throw new Error(`Unsupported input type: ${inputType}`); @@ -62,6 +84,7 @@ export async function generateTypescriptTypes( return { result, - generator + generator, + filesWritten }; } diff --git a/src/codegen/index.ts b/src/codegen/index.ts index 7e6f65ce..5f28cd6f 100644 --- a/src/codegen/index.ts +++ b/src/codegen/index.ts @@ -42,7 +42,9 @@ export { SingleFunctionRenderType, ChannelPayload, GeneratorsInternal, - HeadersRenderType + HeadersRenderType, + GenerationResult, + GeneratorResult } from './types'; export { diff --git a/src/codegen/inputs/asyncapi/generators/types.ts b/src/codegen/inputs/asyncapi/generators/types.ts index 9ef8c9a0..0bfce9e9 100644 --- a/src/codegen/inputs/asyncapi/generators/types.ts +++ b/src/codegen/inputs/asyncapi/generators/types.ts @@ -3,10 +3,15 @@ import {TypescriptTypesGeneratorInternal} from '../../../generators/typescript/t import path from 'path'; import {mkdir, writeFile} from 'fs/promises'; +export interface TypesGeneratorResult { + result: string; + filesWritten: string[]; +} + export async function generateAsyncAPITypes( asyncapiDocument: AsyncAPIDocumentInterface, generator: TypescriptTypesGeneratorInternal -): Promise { +): Promise { const allChannels = asyncapiDocument.allChannels().all(); const channelAddressUnion = allChannels .map((channel) => { @@ -64,7 +69,11 @@ ${allChannels } await mkdir(generator.outputPath, {recursive: true}); - await writeFile(path.resolve(generator.outputPath, 'Types.ts'), result, {}); + const filePath = path.resolve(generator.outputPath, 'Types.ts'); + await writeFile(filePath, result, {}); - return result; + return { + result, + filesWritten: [filePath] + }; } diff --git a/src/codegen/inputs/asyncapi/parser.ts b/src/codegen/inputs/asyncapi/parser.ts index e81c57a8..13f2fb2b 100644 --- a/src/codegen/inputs/asyncapi/parser.ts +++ b/src/codegen/inputs/asyncapi/parser.ts @@ -4,6 +4,8 @@ import {OpenAPISchemaParser} from '@asyncapi/openapi-schema-parser'; import {RamlDTSchemaParser} from '@asyncapi/raml-dt-schema-parser'; import {ProtoBuffSchemaParser} from '@asyncapi/protobuf-schema-parser'; import {RunGeneratorContext} from '../../types'; +import {Logger} from '../../../LoggingInterface'; +import {createInputDocumentError} from '../../errors'; const parser = new Parser({ ruleset: { @@ -23,22 +25,27 @@ export async function loadAsyncapi(context: RunGeneratorContext) { } export async function loadAsyncapiDocument(documentPath: string) { + Logger.verbose(`Loading AsyncAPI document from ${documentPath}`); const document = await fromFile(parser, documentPath).parse(); if (document.diagnostics.length > 0) { - throw new Error( - `Could not load AsyncAPI document, errors was: ${JSON.stringify(document.diagnostics)}` - ); + throw createInputDocumentError({ + inputPath: documentPath, + inputType: 'asyncapi', + errorMessage: JSON.stringify(document.diagnostics, null, 2) + }); } - + Logger.debug(`AsyncAPI document loaded successfully`); return document.document; } export async function loadAsyncapiFromMemory(input: string) { const document = await parser.parse(input); if (document.diagnostics.length > 0) { - throw new Error( - `Could not load AsyncAPI document, errors was: ${JSON.stringify(document.diagnostics)}` - ); + throw createInputDocumentError({ + inputPath: 'memory', + inputType: 'asyncapi', + errorMessage: JSON.stringify(document.diagnostics, null, 2) + }); } return document.document; diff --git a/src/codegen/inputs/jsonschema/parser.ts b/src/codegen/inputs/jsonschema/parser.ts index 8c8f24dc..871bb7d2 100644 --- a/src/codegen/inputs/jsonschema/parser.ts +++ b/src/codegen/inputs/jsonschema/parser.ts @@ -1,6 +1,7 @@ import {Logger} from '../../../LoggingInterface'; import {RunGeneratorContext} from '../../types'; import fs from 'fs'; +import {createInputDocumentError} from '../../errors'; export interface JsonSchemaDocument { [key: string]: any; @@ -18,7 +19,7 @@ export async function loadJsonSchema( context: RunGeneratorContext ): Promise { const {documentPath} = context; - Logger.info(`Loading JSON Schema document from ${documentPath}`); + Logger.verbose(`Loading JSON Schema document from ${documentPath}`); try { const fileContent = fs.readFileSync(documentPath, 'utf8'); @@ -34,22 +35,28 @@ export async function loadJsonSchema( const yaml = await import('yaml'); document = yaml.parse(fileContent); } else { - throw new Error( - `Unsupported file format for JSON Schema: ${documentPath}. Use .json, .yaml, or .yml` - ); + throw createInputDocumentError({ + inputPath: documentPath, + inputType: 'jsonschema', + errorMessage: `Unsupported file format. Use .json, .yaml, or .yml` + }); } validateJsonSchemaDocument(document, documentPath); return document; } catch (error) { if (error instanceof Error) { - throw new Error( - `Failed to load JSON Schema document from ${documentPath}: ${error.message}` - ); + throw createInputDocumentError({ + inputPath: documentPath, + inputType: 'jsonschema', + errorMessage: error.message + }); } - throw new Error( - `Failed to load JSON Schema document from ${documentPath}: Unknown error` - ); + throw createInputDocumentError({ + inputPath: documentPath, + inputType: 'jsonschema', + errorMessage: 'Unknown error' + }); } } @@ -59,7 +66,7 @@ export async function loadJsonSchema( export async function loadJsonSchemaDocument( filePath: string ): Promise { - Logger.info(`Loading JSON Schema document from ${filePath}`); + Logger.verbose(`Loading JSON Schema document from ${filePath}`); try { const fileContent = fs.readFileSync(filePath, 'utf8'); @@ -72,22 +79,28 @@ export async function loadJsonSchemaDocument( const yaml = await import('yaml'); document = yaml.parse(fileContent); } else { - throw new Error( - `Unsupported file format for JSON Schema: ${filePath}. Use .json, .yaml, or .yml` - ); + throw createInputDocumentError({ + inputPath: filePath, + inputType: 'jsonschema', + errorMessage: `Unsupported file format. Use .json, .yaml, or .yml` + }); } validateJsonSchemaDocument(document, filePath); return document; } catch (error) { if (error instanceof Error) { - throw new Error( - `Failed to load JSON Schema document from ${filePath}: ${error.message}` - ); + throw createInputDocumentError({ + inputPath: filePath, + inputType: 'jsonschema', + errorMessage: error.message + }); } - throw new Error( - `Failed to load JSON Schema document from ${filePath}: Unknown error` - ); + throw createInputDocumentError({ + inputPath: filePath, + inputType: 'jsonschema', + errorMessage: 'Unknown error' + }); } } @@ -99,7 +112,7 @@ export function loadJsonSchemaFromMemory( documentPath?: string ): JsonSchemaDocument { const path = documentPath || 'memory'; - Logger.info(`Loading JSON Schema document from ${path}`); + Logger.verbose(`Loading JSON Schema document from ${path}`); validateJsonSchemaDocument(document, path); return document; @@ -113,16 +126,20 @@ function validateJsonSchemaDocument( source: string ): void { if (!document || typeof document !== 'object') { - throw new Error( - `Invalid JSON Schema document from ${source}: Document must be an object` - ); + throw createInputDocumentError({ + inputPath: source, + inputType: 'jsonschema', + errorMessage: 'Document must be an object' + }); } // Basic JSON Schema structure validation if (document.$schema && typeof document.$schema !== 'string') { - throw new Error( - `Invalid JSON Schema document from ${source}: $schema must be a string` - ); + throw createInputDocumentError({ + inputPath: source, + inputType: 'jsonschema', + errorMessage: '$schema must be a string' + }); } // Warn if no $schema is specified @@ -144,5 +161,5 @@ function validateJsonSchemaDocument( ); } - Logger.info(`Successfully validated JSON Schema document from ${source}`); + Logger.debug(`Successfully validated JSON Schema document from ${source}`); } diff --git a/src/codegen/inputs/openapi/generators/types.ts b/src/codegen/inputs/openapi/generators/types.ts index 5bc0a620..05a34725 100644 --- a/src/codegen/inputs/openapi/generators/types.ts +++ b/src/codegen/inputs/openapi/generators/types.ts @@ -4,13 +4,18 @@ import {TypescriptTypesGeneratorInternal} from '../../../generators/typescript/t import path from 'path'; import {mkdir, writeFile} from 'fs/promises'; +export interface TypesGeneratorResult { + result: string; + filesWritten: string[]; +} + export async function generateOpenAPITypes( openapiDocument: | OpenAPIV3.Document | OpenAPIV2.Document | OpenAPIV3_1.Document, generator: TypescriptTypesGeneratorInternal -): Promise { +): Promise { const paths = openapiDocument.paths ?? {}; const allPaths = Object.keys(paths); @@ -109,7 +114,11 @@ ${Object.entries(operationIdToPathMap) } await mkdir(generator.outputPath, {recursive: true}); - await writeFile(path.resolve(generator.outputPath, 'Types.ts'), result, {}); + const filePath = path.resolve(generator.outputPath, 'Types.ts'); + await writeFile(filePath, result, {}); - return result; + return { + result, + filesWritten: [filePath] + }; } diff --git a/src/codegen/inputs/openapi/parser.ts b/src/codegen/inputs/openapi/parser.ts index 0f995664..38d30dea 100644 --- a/src/codegen/inputs/openapi/parser.ts +++ b/src/codegen/inputs/openapi/parser.ts @@ -3,6 +3,8 @@ import {RunGeneratorContext} from '../../types'; import {readFileSync} from 'fs'; import {parse as parseYaml} from 'yaml'; import {OpenAPIV2, OpenAPIV3, OpenAPIV3_1} from 'openapi-types'; +import {Logger} from '../../../LoggingInterface'; +import {createInputDocumentError} from '../../errors'; export async function loadOpenapi( context: RunGeneratorContext @@ -14,15 +16,18 @@ export async function loadOpenapi( export async function loadOpenapiDocument( documentPath: string ): Promise { + Logger.verbose(`Loading OpenAPI document from ${documentPath}`); try { // Read the file content let documentContent: string; try { documentContent = readFileSync(documentPath, 'utf-8'); } catch (error) { - throw new Error( - `Could not read OpenAPI document from '${documentPath}': ${error}` - ); + throw createInputDocumentError({ + inputPath: documentPath, + inputType: 'openapi', + errorMessage: `Could not read file: ${error}` + }); } // Parse the document (support both JSON and YAML) @@ -34,15 +39,25 @@ export async function loadOpenapiDocument( document = JSON.parse(documentContent); } } catch (error) { - throw new Error(`Could not parse OpenAPI document: ${error}`); + throw createInputDocumentError({ + inputPath: documentPath, + inputType: 'openapi', + errorMessage: `Could not parse document: ${error}` + }); } // Parse and validate the OpenAPI document const parsedDocument = await parse(document); // Dereference all $ref pointers to get a fully resolved document - return await dereference(parsedDocument); + const result = await dereference(parsedDocument); + Logger.debug(`OpenAPI document loaded and dereferenced`); + return result; } catch (error) { - throw new Error(`Could not load OpenAPI document: ${error}`); + throw createInputDocumentError({ + inputPath: documentPath, + inputType: 'openapi', + errorMessage: String(error) + }); } } diff --git a/src/codegen/renderer.ts b/src/codegen/renderer.ts index 2b77fbbe..39c3180d 100644 --- a/src/codegen/renderer.ts +++ b/src/codegen/renderer.ts @@ -1,5 +1,7 @@ import {Logger} from '../LoggingInterface'; import { + GenerationResult, + GeneratorResult, Generators, GeneratorsInternal, RenderTypes, @@ -18,6 +20,12 @@ import path from 'path'; import Graph from 'graphology'; import {findDuplicatesInArray} from './utils'; import {generateTypescriptModels} from './generators/typescript/models'; +import { + createUnsupportedLanguageError, + createUnsupportedPresetForInputError, + createDuplicateGeneratorIdError, + createCircularDependencyError +} from './errors'; export type Node = { generator: Generators; @@ -41,21 +49,23 @@ export async function renderGenerator( path.dirname(configFilePath), generator.outputPath ?? '' ); - Logger.info(`Found output path for generator '${outputPath}'`); const language = generator.language ? generator.language : configuration.language; - Logger.info(`Found language for generator '${language}'`); - Logger.info(`Found preset for generator '${generator.preset}'`); + Logger.debug( + `Generator ${generator.id}: outputPath=${outputPath}, language=${language}, preset=${generator.preset}` + ); // Check if this generator is compatible with the input type if ( configuration.inputType === 'jsonschema' && generator.preset !== 'models' && generator.preset !== 'custom' ) { - throw new Error( - `Generator preset '${generator.preset}' is not supported with JSON Schema input. Only 'models' and 'custom' generators are supported.` - ); + throw createUnsupportedPresetForInputError({ + preset: generator.preset, + inputType: 'jsonschema', + supportedPresets: ['models', 'custom'] + }); } switch (generator.preset) { @@ -76,9 +86,10 @@ export async function renderGenerator( } default: { - throw new Error( - 'Unable to determine language generator for payloads preset' - ); + throw createUnsupportedLanguageError({ + preset: 'payloads', + language: language ?? 'unknown' + }); } } } @@ -100,9 +111,10 @@ export async function renderGenerator( } default: { - throw new Error( - 'Unable to determine language generator for parameters preset' - ); + throw createUnsupportedLanguageError({ + preset: 'parameters', + language: language ?? 'unknown' + }); } } } @@ -124,9 +136,10 @@ export async function renderGenerator( } default: { - throw new Error( - 'Unable to determine language generator for headers preset' - ); + throw createUnsupportedLanguageError({ + preset: 'headers', + language: language ?? 'unknown' + }); } } } @@ -148,9 +161,10 @@ export async function renderGenerator( } default: { - throw new Error( - 'Unable to determine language generator for types preset' - ); + throw createUnsupportedLanguageError({ + preset: 'types', + language: language ?? 'unknown' + }); } } } @@ -172,9 +186,10 @@ export async function renderGenerator( } default: { - throw new Error( - 'Unable to determine language generator for channels preset' - ); + throw createUnsupportedLanguageError({ + preset: 'channels', + language: language ?? 'unknown' + }); } } } @@ -196,9 +211,10 @@ export async function renderGenerator( } default: { - throw new Error( - 'Unable to determine language generator for client preset' - ); + throw createUnsupportedLanguageError({ + preset: 'client', + language: language ?? 'unknown' + }); } } } @@ -221,9 +237,10 @@ export async function renderGenerator( } default: { - throw new Error( - 'Unable to determine language generator for models preset' - ); + throw createUnsupportedLanguageError({ + preset: 'models', + language: language ?? 'unknown' + }); } } } @@ -243,7 +260,6 @@ export async function renderGenerator( } // No default } - throw new Error('Unable to determine preset for generator'); } export function determineRenderGraph(context: RunGeneratorContext): GraphType { @@ -253,9 +269,7 @@ export function determineRenderGraph(context: RunGeneratorContext): GraphType { 'id' ); if (duplicateGenerators.length > 0) { - throw new Error( - `There are two or more generators that use the same id, please use unique id's for each generator, id('s) are ${duplicateGenerators.join(', ')}` - ); + throw createDuplicateGeneratorIdError({duplicateIds: duplicateGenerators}); } const graph = new Graph({allowSelfLoops: true, type: 'directed'}); @@ -269,7 +283,7 @@ export function determineRenderGraph(context: RunGeneratorContext): GraphType { } if (graph.selfLoopCount !== 0) { - throw new Error('You are not allowed to have self dependant generators'); + throw createCircularDependencyError(); } return graph; @@ -281,17 +295,18 @@ export function determineRenderGraph(context: RunGeneratorContext): GraphType { export async function renderGraph( context: RunGeneratorContext, graph: GraphType -) { +): Promise { + const startTime = Date.now(); const renderedContext: any = {}; + const generatorResults: GeneratorResult[] = []; + const recursivelyRenderGenerators = async ( nodesToRender: any[], previousCount?: number ) => { const count = nodesToRender.length; if (previousCount === count) { - throw new Error( - 'You are not allowed to have circular dependencies in generators' - ); + throw createCircularDependencyError(); } const nodesToRenderNext: any[] = []; @@ -308,12 +323,24 @@ export async function renderGraph( } if (allRendered) { + const generatorStartTime = Date.now(); + Logger.updateSpinner( + `Generating ${nodeEntry.attributes.generator.preset}...` + ); const result = await renderGenerator( nodeEntry.attributes.generator, context, renderedContext ); renderedContext[nodeEntry.node] = result; + + // Record generator result - extract filesWritten from result + generatorResults.push({ + id: nodeEntry.attributes.generator.id, + preset: nodeEntry.attributes.generator.preset, + filesWritten: (result as any)?.filesWritten ?? [], + duration: Date.now() - generatorStartTime + }); } else { nodesToRenderNext.push(nodeEntry); } @@ -323,4 +350,16 @@ export async function renderGraph( } }; await recursivelyRenderGenerators([...graph.nodeEntries()]); + + // Collect all files (deduplicated) + const allFiles = [ + ...new Set(generatorResults.flatMap((g) => g.filesWritten)) + ]; + + return { + generators: generatorResults, + totalFiles: allFiles.length, + totalDuration: Date.now() - startTime, + allFiles + }; } diff --git a/src/codegen/types.ts b/src/codegen/types.ts index 178f423c..3565fc1a 100644 --- a/src/codegen/types.ts +++ b/src/codegen/types.ts @@ -141,17 +141,21 @@ export type RenderTypes = export interface ParameterRenderType { channelModels: Record; generator: GeneratorType; + filesWritten: string[]; } export interface HeadersRenderType { channelModels: Record; generator: GeneratorType; + filesWritten: string[]; } export interface TypesRenderType { result: string; generator: GeneratorType; + filesWritten: string[]; } export interface ModelsRenderType { generator: GeneratorType; + filesWritten: string[]; } export interface ChannelPayload { messageModel: OutputModel; @@ -167,6 +171,7 @@ export interface PayloadRenderType { operationModels: Record; otherModels: ChannelPayload[]; generator: GeneratorType; + filesWritten: string[]; } export interface SingleFunctionRenderType { functionName: string; @@ -279,3 +284,31 @@ export interface RunGeneratorContext { | OpenAPIV3_1.Document; jsonSchemaDocument?: JsonSchemaDocument; } + +/** + * Result of a single generator execution + */ +export interface GeneratorResult { + /** Generator ID from configuration */ + id: string; + /** Generator preset type */ + preset: string; + /** Files written by this generator (absolute paths) */ + filesWritten: string[]; + /** Duration in milliseconds */ + duration: number; +} + +/** + * Result of the entire generation process + */ +export interface GenerationResult { + /** Results from each generator */ + generators: GeneratorResult[]; + /** Total number of files written */ + totalFiles: number; + /** Total duration in milliseconds */ + totalDuration: number; + /** All file paths written (deduplicated, absolute) */ + allFiles: string[]; +} diff --git a/src/commands/base.ts b/src/commands/base.ts new file mode 100644 index 00000000..c5ba2bac --- /dev/null +++ b/src/commands/base.ts @@ -0,0 +1,56 @@ +/** + * Base command class with shared flags for verbosity and output control + */ +import {Command, Flags} from '@oclif/core'; +import {Logger, LogLevel} from '../LoggingInterface'; + +export abstract class BaseCommand extends Command { + static baseFlags = { + verbose: Flags.boolean({ + char: 'v', + description: 'Show detailed output' + }), + quiet: Flags.boolean({ + char: 'q', + description: 'Only show errors and warnings', + exclusive: ['verbose', 'silent'] + }), + silent: Flags.boolean({ + description: 'Suppress all output except fatal errors', + exclusive: ['verbose', 'quiet'] + }), + json: Flags.boolean({ + description: 'Output results as JSON for scripting' + }), + 'no-color': Flags.boolean({ + description: 'Disable colored output' + }), + debug: Flags.boolean({ + description: 'Show debug information', + exclusive: ['quiet', 'silent'] + }) + }; + + /** + * Configure the logger based on parsed flags + */ + protected setupLogger(flags: Record): void { + // Reset logger to default state first to ensure clean state between commands + Logger.reset(); + + let logLevel: LogLevel = 'info'; + if (flags.debug) { + logLevel = 'debug'; + } else if (flags.verbose) { + logLevel = 'verbose'; + } else if (flags.quiet) { + logLevel = 'warn'; + } else if (flags.silent) { + logLevel = 'silent'; + } + + Logger.setLevel(logLevel); + Logger.setJsonMode(!!flags.json); + Logger.setColors(!flags['no-color']); + } +} diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 4f219e7e..08d1b6db 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -1,16 +1,35 @@ -import {Args, Command, Flags} from '@oclif/core'; +/* eslint-disable no-undef, sonarjs/cognitive-complexity */ +import {Args, Flags} from '@oclif/core'; import {Logger} from '../LoggingInterface'; import {generateWithConfig} from '../codegen/generators'; import chokidar from 'chokidar'; import path from 'path'; import {realizeGeneratorContext} from '../codegen/configurations'; -import {enhanceError} from '../codegen/errors'; +import {CodegenError, createGeneratorError} from '../codegen/errors'; import {trackEvent} from '../telemetry'; import {getInputSourceType, categorizeError} from '../telemetry/anonymize'; -import {RunGeneratorContext} from '../codegen'; -export default class Generate extends Command { +import {GenerationResult, RunGeneratorContext} from '../codegen'; +import {BaseCommand} from './base'; +import pc from 'picocolors'; + +/** + * Converts any error to a CodegenError for consistent error handling. + * If error is already a CodegenError, returns it directly. + * Otherwise wraps it in a generic generator error. + */ +function toCodegenError(error: unknown): CodegenError { + if (error instanceof CodegenError) { + return error; + } + return createGeneratorError({ + generatorId: 'generate', + originalError: error instanceof Error ? error : new Error(String(error)) + }); +} + +export default class Generate extends BaseCommand { static description = - 'Generate code based on your configuration, use `init` to get started.'; + 'Generate code based on your configuration, use `init` to get started, `generate` to generate code from the configuration.'; static args = { file: Args.string({ description: @@ -28,30 +47,17 @@ export default class Generate extends Command { char: 'p', description: 'Optional path to watch for changes when --watch flag is used. If not provided, watches the input file from configuration' - }) + }), + ...BaseCommand.baseFlags }; async run() { const startTime = Date.now(); const {args, flags} = await this.parse(Generate); - Logger.setLogger({ - info: (message: string, ...optionalParams: any[]) => { - this.log(message, ...optionalParams); - }, - debug: (message: string, ...optionalParams: any[]) => { - this.debug(message, ...optionalParams); - }, - warn: (message: string, ...optionalParams: any[]) => { - this.warn( - `${message}, additional info: ${optionalParams.map((param) => JSON.stringify(param)).join(' | ')}` - ); - }, - error: (message: string, ...optionalParams: any[]) => { - this.error( - `${message}, additional info: ${optionalParams.map((param) => JSON.stringify(param)).join(' | ')}` - ); - } - }); + + // Configure logger based on flags + this.setupLogger(flags); + const {file} = args; const {watch, watchPath} = flags; @@ -65,14 +71,29 @@ export default class Generate extends Command { inputSource = getInputSourceType(file); } - const context = await realizeGeneratorContext(file); + let context: RunGeneratorContext; + try { + context = await realizeGeneratorContext(file); + } catch (configError: unknown) { + const codegenError = toCodegenError(configError); + Logger.error(codegenError.format(!flags['no-color'])); + // Use error message that includes details for better test assertions + const errorMessage = codegenError.details + ? `${codegenError.message}: ${codegenError.details}` + : codegenError.message; + this.error(errorMessage, {exit: 1}); + } try { + let result: GenerationResult | undefined; + if (watch) { await this.handleWatchModeStartedTelemetry({context, inputSource}); - await this.runWithWatch({configFile: file, watchPath}); + await this.runWithWatch({configFile: file, watchPath, flags}); } else { - await generateWithConfig(context); + Logger.startSpinner('Generating code...'); + result = await generateWithConfig(context); + this.handleSuccessOutput(result, flags); } await this.handleGeneratorUsageTelemetry({context, inputSource}); @@ -83,6 +104,9 @@ export default class Generate extends Command { startTime }); } catch (error: unknown) { + Logger.failSpinner('Generation failed'); + const codegenError = toCodegenError(error); + Logger.error(codegenError.format(!flags['no-color'])); await this.handleFailedGenerateTelemetry({ error, context, @@ -90,20 +114,69 @@ export default class Generate extends Command { inputSource, startTime }); + // Use error message that includes details for better test assertions + const errorMessage = codegenError.details + ? `${codegenError.message}: ${codegenError.details}` + : codegenError.message; + this.error(errorMessage, {exit: 1}); + } + } + + /** + * Handle successful generation output based on flags + */ + private handleSuccessOutput( + result: GenerationResult, + flags: {verbose?: boolean; json?: boolean; 'no-color'?: boolean} + ): void { + Logger.succeedSpinner( + `Generated ${result.totalFiles} file${result.totalFiles !== 1 ? 's' : ''} in ${result.totalDuration}ms` + ); + + // Verbose: show file list + if (flags.verbose && !flags.json) { + for (const file of result.allFiles) { + const relativePath = path.relative(process.cwd(), file); + Logger.verbose(` -> ${relativePath}`); + } + } + + // JSON output + if (flags.json) { + Logger.json({ + success: true, + files: result.allFiles.map((file) => + path.relative(process.cwd(), file) + ), + generators: result.generators.map((gen) => ({ + id: gen.id, + preset: gen.preset, + files: gen.filesWritten.map((file) => + path.relative(process.cwd(), file) + ), + duration: gen.duration + })), + totalFiles: result.totalFiles, + duration: result.totalDuration + }); } } private async runWithWatch({ configFile, - watchPath + watchPath, + flags }: { configFile?: string; watchPath?: string; + flags: {verbose?: boolean; json?: boolean; 'no-color'?: boolean}; }): Promise { // Initial generation - Logger.info('Generating initial code...'); - await generateWithConfig(configFile); - Logger.info('Initial generation complete. Starting watch mode...'); + Logger.startSpinner('Generating initial code...'); + const initialResult = await generateWithConfig(configFile); + Logger.succeedSpinner( + `Initial generation complete (${initialResult.totalFiles} file${initialResult.totalFiles !== 1 ? 's' : ''})` + ); // Determine what to watch let pathToWatch: string; @@ -115,7 +188,15 @@ export default class Generate extends Command { pathToWatch = context.documentPath; } - Logger.info(`Watching for changes in: ${pathToWatch}`); + const useColors = !flags['no-color']; + Logger.info( + `\nWatching for changes in: ${useColors ? pc.cyan(pathToWatch) : pathToWatch}` + ); + Logger.info( + useColors + ? pc.dim('Press Ctrl+C to stop watching...') + : 'Press Ctrl+C to stop watching...' + ); // Set up file watcher const watcher = chokidar.watch(pathToWatch, { @@ -133,31 +214,40 @@ export default class Generate extends Command { } isGenerating = true; - Logger.info(`File changed: ${changedPath}`); - Logger.info('Regenerating code...'); + Logger.startSpinner( + `Regenerating (${path.basename(changedPath)} changed)...` + ); try { - await generateWithConfig(configFile); - Logger.info('Code regenerated successfully'); + const result = await generateWithConfig(configFile); + Logger.succeedSpinner( + `Regenerated ${result.totalFiles} file${result.totalFiles !== 1 ? 's' : ''} in ${result.totalDuration}ms` + ); + + // Verbose: show file list + if (flags.verbose && !flags.json) { + for (const file of result.allFiles) { + const relativePath = path.relative(process.cwd(), file); + Logger.verbose(` -> ${relativePath}`); + } + } } catch (error: unknown) { - const codegenError = enhanceError(error); - Logger.error(codegenError.format()); + const codegenError = toCodegenError(error); + Logger.failSpinner('Regeneration failed'); + Logger.error(codegenError.format(!flags['no-color'])); } finally { isGenerating = false; } }); watcher.on('error', (error: unknown) => { - const codegenError = enhanceError(error); - Logger.error(codegenError.format()); + const codegenError = toCodegenError(error); + Logger.error(codegenError.format(!flags['no-color'])); }); - // Keep the process running - Logger.info('Press Ctrl+C to stop watching...'); - // Set up graceful shutdown handlers const cleanup = () => { - Logger.info('Stopping file watcher...'); + Logger.info('\nStopping file watcher...'); watcher.close(); }; diff --git a/src/commands/init.ts b/src/commands/init.ts index 59b89377..e52f2c25 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,7 +1,7 @@ /* eslint-disable sonarjs/no-duplicate-string */ /* eslint-disable sonarjs/no-collapsible-if */ /* eslint-disable prefer-const */ -import {Command, Flags} from '@oclif/core'; +import {Flags} from '@oclif/core'; import {writeFile} from 'node:fs/promises'; import path from 'path'; import YAML from 'yaml'; @@ -16,6 +16,7 @@ import { } from '../codegen/generators'; import {updateGitignore} from '../utils/gitignore'; import {trackEvent} from '../telemetry'; +import {BaseCommand} from './base'; const ConfigOptions = ['esm', 'json', 'yaml', 'ts'] as const; const LanguageOptions = ['typescript'] as const; @@ -69,12 +70,23 @@ interface InquirerAnswers { includeChannels?: boolean; gitignoreGenerated?: boolean; } -export default class Init extends Command { +export default class Init extends BaseCommand { static description = 'Initialize The Codegen Project in your project'; static args = {}; static flags = { help: Flags.help(), + verbose: Flags.boolean({ + char: 'v', + description: 'Show detailed output' + }), + quiet: Flags.boolean({ + char: 'q', + description: 'Only show errors and warnings' + }), + 'no-color': Flags.boolean({ + description: 'Disable colored output' + }), 'input-file': Flags.file({ description: map.inputFile.description }), @@ -203,6 +215,10 @@ export default class Init extends Command { async run() { const {flags} = await this.parse(Init); + + // Configure logger based on flags + this.setupLogger(flags); + // eslint-disable-next-line no-undef const isTTY = process.stdout.isTTY; const realizedFlags = this.realizeFlags(flags); diff --git a/src/commands/telemetry.ts b/src/commands/telemetry.ts index b153dfb6..0b99022a 100644 --- a/src/commands/telemetry.ts +++ b/src/commands/telemetry.ts @@ -1,5 +1,5 @@ /* eslint-disable no-undef */ -import {Args, Command, Flags} from '@oclif/core'; +import {Args, Flags} from '@oclif/core'; import {Logger} from '../LoggingInterface'; import { getTelemetryConfig, @@ -7,8 +7,10 @@ import { isTelemetryEnabled } from '../telemetry/config'; import {getConfigFilePath} from '../PersistedConfig'; +import {BaseCommand} from './base'; +import pc from 'picocolors'; -export default class Telemetry extends Command { +export default class Telemetry extends BaseCommand { static description = 'Manage telemetry settings'; static args = { @@ -20,7 +22,10 @@ export default class Telemetry extends Command { }; static flags = { - help: Flags.help() + help: Flags.help(), + 'no-color': Flags.boolean({ + description: 'Disable colored output' + }) }; static examples = [ @@ -30,38 +35,24 @@ export default class Telemetry extends Command { ]; async run() { - const {args} = await this.parse(Telemetry); - - Logger.setLogger({ - info: (message: string, ...optionalParams: any[]) => { - this.log(message, ...optionalParams); - }, - debug: (message: string, ...optionalParams: any[]) => { - this.debug(message, ...optionalParams); - }, - warn: (message: string, ...optionalParams: any[]) => { - this.warn( - `${message}, additional info: ${optionalParams.map((param) => JSON.stringify(param)).join(' | ')}` - ); - }, - error: (message: string, ...optionalParams: any[]) => { - this.error( - `${message}, additional info: ${optionalParams.map((param) => JSON.stringify(param)).join(' | ')}` - ); - } - }); + const {args, flags} = await this.parse(Telemetry); + + // Configure logger based on flags + this.setupLogger(flags); + const {action} = args; + const useColors = !flags['no-color']; try { switch (action) { case 'status': - await this.showStatus(); + await this.showStatus(useColors); break; case 'enable': - await this.enableTelemetry(); + await this.enableTelemetry(useColors); break; case 'disable': - await this.disableTelemetry(); + await this.disableTelemetry(useColors); break; default: this.error( @@ -77,54 +68,75 @@ export default class Telemetry extends Command { /** * Show telemetry status */ - private async showStatus(): Promise { + private async showStatus(useColors: boolean): Promise { const enabled = await isTelemetryEnabled(); const config = await getTelemetryConfig(); const configPath = getConfigFilePath(); - Logger.info('\n┌─────────────────────────────────────────────┐'); - Logger.info('│ Telemetry Status │'); - Logger.info('└─────────────────────────────────────────────┘\n'); + const c = useColors + ? pc + : { + cyan: (s: string) => s, + green: (s: string) => s, + red: (s: string) => s, + dim: (s: string) => s + }; - Logger.info(`Status: ${enabled ? '✅ ENABLED' : '❌ DISABLED'}`); - Logger.info(`\nConfig file: ${configPath}`); - Logger.info(`Anonymous ID: ${config.anonymousId}`); - Logger.info(`Endpoint: ${config.endpoint}`); + Logger.info( + `\n${c.cyan('┌─────────────────────────────────────────────┐')}` + ); + Logger.info( + `${c.cyan('│')} Telemetry Status ${c.cyan('│')}` + ); + Logger.info( + `${c.cyan('└─────────────────────────────────────────────┘')}\n` + ); + + Logger.info(`Status: ${enabled ? c.green('ENABLED') : c.red('DISABLED')}`); + Logger.info(`\nConfig file: ${c.dim(configPath)}`); + Logger.info(`Anonymous ID: ${c.dim(config.anonymousId)}`); + Logger.info(`Endpoint: ${c.dim(config.endpoint)}`); Logger.info('\nWhat we collect:'); - Logger.info(' ✓ Command usage and flags'); - Logger.info(' ✓ Generator types used'); - Logger.info(' ✓ Input source types (remote/local)'); - Logger.info(' ✓ CLI version and Node.js version'); - Logger.info(' ✓ Error categories (not error messages)'); + Logger.info(` ${c.green('✓')} Command usage and flags`); + Logger.info(` ${c.green('✓')} Generator types used`); + Logger.info(` ${c.green('✓')} Input source types (remote/local)`); + Logger.info(` ${c.green('✓')} CLI version and Node.js version`); + Logger.info(` ${c.green('✓')} Error categories (not error messages)`); Logger.info("\nWhat we DON'T collect:"); - Logger.info(' ✗ File paths or file names'); - Logger.info(' ✗ File contents'); - Logger.info(' ✗ Personal information'); - Logger.info(' ✗ Project names'); + Logger.info(` ${c.red('✗')} File paths or file names`); + Logger.info(` ${c.red('✗')} File contents`); + Logger.info(` ${c.red('✗')} Personal information`); + Logger.info(` ${c.red('✗')} Project names`); Logger.info( - '\nLearn more: https://the-codegen-project.org/docs/telemetry\n' + `\nLearn more: ${c.cyan('https://the-codegen-project.org/docs/telemetry')}\n` ); } /** * Enable telemetry */ - private async enableTelemetry(): Promise { + private async enableTelemetry(useColors: boolean): Promise { await setTelemetryEnabled(true); + const c = useColors ? pc : {cyan: (s: string) => s}; + Logger.info('✅ Telemetry enabled'); Logger.info('\nThank you for helping us improve The Codegen Project!'); - Logger.info('Learn more: https://the-codegen-project.org/docs/telemetry\n'); + Logger.info( + `Learn more: ${c.cyan('https://the-codegen-project.org/docs/telemetry')}\n` + ); } /** * Disable telemetry */ - private async disableTelemetry(): Promise { + private async disableTelemetry(useColors: boolean): Promise { await setTelemetryEnabled(false); + const c = useColors ? pc : {dim: (s: string) => s}; + Logger.info('✅ Telemetry disabled'); Logger.info('\nYou can re-enable telemetry anytime with:'); - Logger.info(' codegen telemetry enable\n'); + Logger.info(` ${c.dim('codegen telemetry enable')}\n`); } } diff --git a/test/LoggingInterface.spec.ts b/test/LoggingInterface.spec.ts new file mode 100644 index 00000000..553bcfa6 --- /dev/null +++ b/test/LoggingInterface.spec.ts @@ -0,0 +1,398 @@ +/* eslint-disable no-console */ +import {Logger, LoggerClass} from '../src/LoggingInterface'; + +describe('LoggingInterface', () => { + let testLogger: LoggerClass; + + beforeEach(() => { + testLogger = new LoggerClass(); + // Disable colors for predictable test output + testLogger.setColors(false); + // Set to debug level to capture all logs + testLogger.setLevel('debug'); + }); + + afterEach(() => { + testLogger.stopSpinner(); + // Reset the singleton Logger to ensure clean state for other tests + Logger.reset(); + }); + + describe('log levels', () => { + it('should respect log level priority', () => { + const logs: string[] = []; + testLogger.setLogger({ + debug: (msg: string) => logs.push(`debug:${msg}`), + info: (msg: string) => logs.push(`info:${msg}`), + warn: (msg: string) => logs.push(`warn:${msg}`), + error: (msg: string) => logs.push(`error:${msg}`) + }); + + // At 'info' level, debug should not be logged + testLogger.setLevel('info'); + testLogger.debug('debug message'); + testLogger.info('info message'); + testLogger.warn('warn message'); + testLogger.error('error message'); + + expect(logs).toContain('info:info message'); + expect(logs).toContain('warn:warn message'); + expect(logs).toContain('error:error message'); + expect(logs.filter((l) => l.includes('debug message'))).toHaveLength(0); + }); + + it('should log nothing at silent level', () => { + const logs: string[] = []; + testLogger.setLogger({ + debug: (msg: string) => logs.push(`debug:${msg}`), + info: (msg: string) => logs.push(`info:${msg}`), + warn: (msg: string) => logs.push(`warn:${msg}`), + error: (msg: string) => logs.push(`error:${msg}`) + }); + + testLogger.setLevel('silent'); + testLogger.debug('debug'); + testLogger.info('info'); + testLogger.warn('warn'); + testLogger.error('error'); + + expect(logs).toHaveLength(0); + }); + + it('should log everything at debug level', () => { + const logs: string[] = []; + testLogger.setLogger({ + debug: (msg: string) => logs.push(`debug:${msg}`), + info: (msg: string) => logs.push(`info:${msg}`), + warn: (msg: string) => logs.push(`warn:${msg}`), + error: (msg: string) => logs.push(`error:${msg}`) + }); + + testLogger.setLevel('debug'); + testLogger.debug('debug'); + testLogger.verbose('verbose'); + testLogger.info('info'); + testLogger.warn('warn'); + testLogger.error('error'); + + expect(logs.length).toBeGreaterThanOrEqual(5); + }); + }); + + describe('JSON mode', () => { + it('should suppress regular output in JSON mode', () => { + const logs: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => logs.push(msg); + + testLogger.setJsonMode(true); + testLogger.info('should not appear'); + + console.log = originalLog; + expect(logs.filter((l) => l.includes('should not appear'))).toHaveLength( + 0 + ); + }); + + it('should output JSON when json() is called', () => { + const logs: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => logs.push(msg); + + testLogger.json({test: 'data'}); + + console.log = originalLog; + expect(logs.join('\n')).toContain('"test": "data"'); + }); + }); + + describe('getLevel and isJsonMode', () => { + it('should return current log level', () => { + testLogger.setLevel('warn'); + expect(testLogger.getLevel()).toBe('warn'); + }); + + it('should return JSON mode state', () => { + expect(testLogger.isJsonMode()).toBe(false); + testLogger.setJsonMode(true); + expect(testLogger.isJsonMode()).toBe(true); + }); + }); + + describe('setLogger', () => { + it('should use custom logger when set', () => { + const customLogs: string[] = []; + testLogger.setLogger({ + debug: () => {}, + info: (msg: string) => customLogs.push(msg), + warn: () => {}, + error: () => {} + }); + + testLogger.info('custom log'); + expect(customLogs).toContain('custom log'); + }); + + it('should use stdout when no logger is set', () => { + const logs: string[] = []; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string) => { + logs.push(chunk); + return true; + }) as typeof process.stdout.write; + + testLogger.setLogger(undefined); + testLogger.info('stdout log'); + + process.stdout.write = originalWrite; + expect(logs.some((l) => l.includes('stdout log'))).toBe(true); + }); + }); + + describe('spinner methods', () => { + it('should not throw when spinner methods are called', () => { + // In non-TTY environment, these should just log text + expect(() => testLogger.startSpinner('Loading...')).not.toThrow(); + expect(() => testLogger.updateSpinner('Still loading...')).not.toThrow(); + expect(() => testLogger.succeedSpinner('Done!')).not.toThrow(); + expect(() => testLogger.failSpinner('Failed!')).not.toThrow(); + expect(() => testLogger.stopSpinner()).not.toThrow(); + }); + + it('should not start spinner in JSON mode', () => { + testLogger.setJsonMode(true); + testLogger.startSpinner('Loading...'); + // No error thrown, spinner should be null + expect(() => testLogger.stopSpinner()).not.toThrow(); + }); + + it('should use spinner text as fallback when succeedSpinner has no argument', () => { + const logs: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + testLogger.startSpinner('Loading data...'); + testLogger.succeedSpinner(); // No text argument - should use spinner text + + expect(logs.some((l) => l.includes('Loading data...'))).toBe(true); + } finally { + console.log = originalLog; + } + }); + + it('should use spinner text as fallback when failSpinner has no argument', () => { + const logs: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + testLogger.startSpinner('Connecting...'); + testLogger.failSpinner(); // No text argument - should use spinner text + + expect(logs.some((l) => l.includes('Connecting...'))).toBe(true); + } finally { + console.log = originalLog; + } + }); + + it('should use provided text over spinner text in succeedSpinner', () => { + const logs: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + testLogger.startSpinner('Loading...'); + testLogger.succeedSpinner('Custom success message'); + + // The success message should contain the custom text with the success symbol + expect(logs.some((l) => l.includes('Custom success message'))).toBe( + true + ); + // Verify the success line specifically uses custom text, not spinner text + const successLine = logs.find((l) => l.includes('[OK]')); + expect(successLine).toContain('Custom success message'); + expect(successLine).not.toContain('Loading...'); + } finally { + console.log = originalLog; + } + }); + + it('should use provided text over spinner text in failSpinner', () => { + const logs: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + testLogger.startSpinner('Loading...'); + testLogger.failSpinner('Custom failure message'); + + // The failure message should contain the custom text with the fail symbol + expect(logs.some((l) => l.includes('Custom failure message'))).toBe( + true + ); + // Verify the failure line specifically uses custom text, not spinner text + const failLine = logs.find((l) => l.includes('[FAIL]')); + expect(failLine).toContain('Custom failure message'); + expect(failLine).not.toContain('Loading...'); + } finally { + console.log = originalLog; + } + }); + + it('should update spinner text correctly', () => { + const logs: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + testLogger.startSpinner('Initial text'); + testLogger.updateSpinner('Updated text'); + testLogger.succeedSpinner(); // Should use updated text + + // Verify the success line uses updated text, not initial text + const successLine = logs.find((l) => l.includes('[OK]')); + expect(successLine).toContain('Updated text'); + expect(successLine).not.toContain('Initial text'); + } finally { + console.log = originalLog; + } + }); + }); + + describe('spinner pause/resume behavior', () => { + let originalIsTTY: boolean | undefined; + let originalClearLine: typeof process.stdout.clearLine; + let originalCursorTo: typeof process.stdout.cursorTo; + let originalWrite: typeof process.stdout.write; + + beforeEach(() => { + // Save original values + originalIsTTY = process.stdout.isTTY; + originalClearLine = process.stdout.clearLine; + originalCursorTo = process.stdout.cursorTo; + originalWrite = process.stdout.write; + + // Mock TTY mode + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + writable: true, + configurable: true + }); + process.stdout.clearLine = jest.fn().mockReturnValue(true); + process.stdout.cursorTo = jest.fn().mockReturnValue(true); + }); + + afterEach(() => { + // Restore original values + Object.defineProperty(process.stdout, 'isTTY', { + value: originalIsTTY, + writable: true, + configurable: true + }); + process.stdout.clearLine = originalClearLine; + process.stdout.cursorTo = originalCursorTo; + process.stdout.write = originalWrite; + testLogger.stopSpinner(); + }); + + it('should resume spinner after logging pauses it', () => { + // Track setInterval calls + const originalSetInterval = global.setInterval; + let intervalCallCount = 0; + global.setInterval = jest.fn( + (callback: () => void, ms?: number) => { + intervalCallCount++; + return originalSetInterval(callback, ms); + } + ) as unknown as typeof setInterval; + + // Mock stdout.write to prevent actual output + process.stdout.write = jest.fn().mockReturnValue(true); + + try { + testLogger.startSpinner('Working...'); + expect(intervalCallCount).toBe(1); // Spinner started + + // Simulate logging which pauses and resumes spinner + testLogger.info('Intermediate message'); + + // After logging, spinner should have been resumed (setInterval called again) + expect(intervalCallCount).toBe(2); + } finally { + global.setInterval = originalSetInterval; + } + }); + + it('should not resume spinner after it has been stopped', () => { + const originalSetInterval = global.setInterval; + let intervalCallCount = 0; + global.setInterval = jest.fn( + (callback: () => void, ms?: number) => { + intervalCallCount++; + return originalSetInterval(callback, ms); + } + ) as unknown as typeof setInterval; + + process.stdout.write = jest.fn().mockReturnValue(true); + + try { + testLogger.startSpinner('Working...'); + expect(intervalCallCount).toBe(1); + + testLogger.stopSpinner(); + + // Logging after stop should not try to resume spinner + testLogger.info('Message after stop'); + + // No new interval should be created + expect(intervalCallCount).toBe(1); + } finally { + global.setInterval = originalSetInterval; + } + }); + }); + + describe('verbose logging', () => { + it('should log at verbose level when level is verbose or higher', () => { + const logs: string[] = []; + testLogger.setLogger({ + debug: () => {}, + info: (msg: string) => logs.push(msg), + warn: () => {}, + error: () => {} + }); + + testLogger.setLevel('verbose'); + testLogger.verbose('verbose message'); + + expect(logs.filter((l) => l.includes('verbose message'))).toHaveLength(1); + }); + + it('should not log at verbose level when level is info', () => { + const logs: string[] = []; + testLogger.setLogger({ + debug: () => {}, + info: (msg: string) => logs.push(msg), + warn: () => {}, + error: () => {} + }); + + testLogger.setLevel('info'); + testLogger.verbose('verbose message'); + + expect(logs.filter((l) => l.includes('verbose message'))).toHaveLength(0); + }); + }); + + describe('Logger singleton', () => { + it('should be an instance of LoggerClass', () => { + expect(Logger).toBeInstanceOf(LoggerClass); + }); + + it('should have default info level', () => { + const freshLogger = new LoggerClass(); + expect(freshLogger.getLevel()).toBe('info'); + }); + }); +}); diff --git a/test/codegen/errors.spec.ts b/test/codegen/errors.spec.ts index 6bb04b19..247ccf38 100644 --- a/test/codegen/errors.spec.ts +++ b/test/codegen/errors.spec.ts @@ -9,8 +9,14 @@ import { createConfigValidationError, createInputDocumentError, createGeneratorError, - parseZodErrors, - enhanceError + createUnsupportedLanguageError, + createMissingInputDocumentError, + createMissingPayloadError, + createMissingParameterError, + createCircularDependencyError, + createDuplicateGeneratorIdError, + createUnsupportedPresetForInputError, + parseZodErrors } from '../../src/codegen/errors'; import {z} from 'zod'; @@ -40,10 +46,10 @@ describe('Error Handling', () => { }); const formatted = error.format(); - expect(formatted).toContain('❌ Test error'); - expect(formatted).toContain('📋 Details:'); + expect(formatted).toContain('Test error'); + expect(formatted).toContain('Details:'); expect(formatted).toContain('Test details'); - expect(formatted).toContain('💡 How to fix:'); + expect(formatted).toContain('How to fix:'); expect(formatted).toContain('Test help'); }); @@ -54,15 +60,15 @@ describe('Error Handling', () => { }); const formatted = error.format(); - expect(formatted).toContain('❌ Test error'); - expect(formatted).not.toContain('📋 Details:'); - expect(formatted).not.toContain('💡 How to fix:'); + expect(formatted).toContain('Test error'); + expect(formatted).not.toContain('Details:'); + expect(formatted).not.toContain('How to fix:'); }); }); describe('createConfigNotFoundError', () => { it('should create error for specific file path', () => { - const error = createConfigNotFoundError('/path/to/config.json'); + const error = createConfigNotFoundError({filePath: '/path/to/config.json'}); expect(error.type).toBe(ErrorType.CONFIG_NOT_FOUND); expect(error.message).toContain('/path/to/config.json'); @@ -71,7 +77,7 @@ describe('Error Handling', () => { it('should create error with search locations', () => { const locations = ['codegen.json', 'codegen.yaml']; - const error = createConfigNotFoundError(undefined, locations); + const error = createConfigNotFoundError({searchLocations: locations}); expect(error.type).toBe(ErrorType.CONFIG_NOT_FOUND); expect(error.message).toContain('No configuration file found'); @@ -84,7 +90,7 @@ describe('Error Handling', () => { describe('createConfigParseError', () => { it('should create parse error with details', () => { const originalError = new Error('Unexpected token'); - const error = createConfigParseError('/path/to/config.js', originalError); + const error = createConfigParseError({filePath: '/path/to/config.js', originalError}); expect(error.type).toBe(ErrorType.CONFIG_PARSE_ERROR); expect(error.message).toContain('/path/to/config.js'); @@ -95,7 +101,7 @@ describe('Error Handling', () => { describe('createInvalidPresetError', () => { it('should create error with valid presets list', () => { - const error = createInvalidPresetError('invalid-preset', 'typescript'); + const error = createInvalidPresetError({preset: 'invalid-preset', language: 'typescript'}); expect(error.type).toBe(ErrorType.INVALID_PRESET); expect(error.message).toContain('invalid-preset'); @@ -108,7 +114,7 @@ describe('Error Handling', () => { describe('createInvalidInputTypeError', () => { it('should create error with valid input types', () => { - const error = createInvalidInputTypeError('invalid-type'); + const error = createInvalidInputTypeError({inputType: 'invalid-type'}); expect(error.type).toBe(ErrorType.INVALID_INPUT_TYPE); expect(error.message).toContain('invalid-type'); @@ -121,7 +127,7 @@ describe('Error Handling', () => { describe('createMissingRequiredFieldError', () => { it('should create error with field name', () => { - const error = createMissingRequiredFieldError('inputPath'); + const error = createMissingRequiredFieldError({field: 'inputPath'}); expect(error.type).toBe(ErrorType.MISSING_REQUIRED_FIELD); expect(error.message).toContain('inputPath'); @@ -129,7 +135,7 @@ describe('Error Handling', () => { }); it('should create error with field name and location', () => { - const error = createMissingRequiredFieldError('outputPath', 'generators[0]'); + const error = createMissingRequiredFieldError({field: 'outputPath', location: 'generators[0]'}); expect(error.type).toBe(ErrorType.MISSING_REQUIRED_FIELD); expect(error.message).toContain('outputPath'); @@ -140,7 +146,7 @@ describe('Error Handling', () => { describe('createConfigValidationError', () => { it('should create error with validation messages', () => { const errors = ['Error 1', 'Error 2', 'Error 3']; - const error = createConfigValidationError(errors); + const error = createConfigValidationError({validationErrors: errors}); expect(error.type).toBe(ErrorType.CONFIG_VALIDATION_ERROR); expect(error.message).toContain('validation failed'); @@ -152,8 +158,11 @@ describe('Error Handling', () => { describe('createInputDocumentError', () => { it('should create error with document path', () => { - const originalError = new Error('Invalid schema'); - const error = createInputDocumentError('/path/to/schema.yml', originalError); + const error = createInputDocumentError({ + inputPath: '/path/to/schema.yml', + inputType: 'asyncapi', + errorMessage: 'Invalid schema' + }); expect(error.type).toBe(ErrorType.INPUT_DOCUMENT_ERROR); expect(error.message).toContain('/path/to/schema.yml'); @@ -165,7 +174,7 @@ describe('Error Handling', () => { describe('createGeneratorError', () => { it('should create error with generator id', () => { const originalError = new Error('Generation failed'); - const error = createGeneratorError('payloads-typescript', originalError); + const error = createGeneratorError({generatorId: 'payloads-typescript', originalError}); expect(error.type).toBe(ErrorType.GENERATOR_ERROR); expect(error.message).toContain('payloads-typescript'); @@ -174,6 +183,121 @@ describe('Error Handling', () => { }); }); + describe('createUnsupportedLanguageError', () => { + it('should create error with preset and language', () => { + const error = createUnsupportedLanguageError({preset: 'channels', language: 'python'}); + + expect(error.type).toBe(ErrorType.UNSUPPORTED_LANGUAGE); + expect(error.message).toContain('python'); + expect(error.message).toContain('channels'); + expect(error.details).toContain('typescript'); + expect(error.help).toContain('language'); + }); + }); + + describe('createMissingInputDocumentError', () => { + it('should create error with expected type', () => { + const error = createMissingInputDocumentError({expectedType: 'asyncapi'}); + + expect(error.type).toBe(ErrorType.MISSING_INPUT_DOCUMENT); + expect(error.message).toContain('asyncapi'); + expect(error.help).toContain('inputType'); + expect(error.help).toContain('inputPath'); + }); + + it('should create error with generator preset context', () => { + const error = createMissingInputDocumentError({expectedType: 'openapi', generatorPreset: 'payloads'}); + + expect(error.type).toBe(ErrorType.MISSING_INPUT_DOCUMENT); + expect(error.message).toContain('openapi'); + expect(error.message).toContain('payloads'); + }); + }); + + describe('createMissingPayloadError', () => { + it('should create error with channel name', () => { + const error = createMissingPayloadError({channelOrOperation: 'user/signup'}); + + expect(error.type).toBe(ErrorType.MISSING_PAYLOAD); + expect(error.message).toContain('user/signup'); + expect(error.help).toContain('payloads'); + }); + + it('should create error with protocol context', () => { + const error = createMissingPayloadError({channelOrOperation: 'user/signup', protocol: 'NATS'}); + + expect(error.type).toBe(ErrorType.MISSING_PAYLOAD); + expect(error.message).toContain('user/signup'); + expect(error.message).toContain('NATS'); + }); + }); + + describe('createMissingParameterError', () => { + it('should create error with channel name', () => { + const error = createMissingParameterError({channelOrOperation: 'orders/{orderId}'}); + + expect(error.type).toBe(ErrorType.MISSING_PARAMETER); + expect(error.message).toContain('orders/{orderId}'); + expect(error.help).toContain('parameters'); + }); + + it('should create error with protocol context', () => { + const error = createMissingParameterError({channelOrOperation: 'orders/{orderId}', protocol: 'Kafka'}); + + expect(error.type).toBe(ErrorType.MISSING_PARAMETER); + expect(error.message).toContain('orders/{orderId}'); + expect(error.message).toContain('Kafka'); + }); + }); + + describe('createCircularDependencyError', () => { + it('should create error without specific generator ids', () => { + const error = createCircularDependencyError(); + + expect(error.type).toBe(ErrorType.CIRCULAR_DEPENDENCY); + expect(error.message).toContain('Circular dependency'); + expect(error.help).toContain('dependencies'); + }); + + it('should create error with specific generator ids', () => { + const error = createCircularDependencyError({generatorIds: ['gen-a', 'gen-b', 'gen-c']}); + + expect(error.type).toBe(ErrorType.CIRCULAR_DEPENDENCY); + expect(error.message).toContain('gen-a'); + expect(error.message).toContain('gen-b'); + expect(error.message).toContain('gen-c'); + }); + }); + + describe('createDuplicateGeneratorIdError', () => { + it('should create error with duplicate ids', () => { + const error = createDuplicateGeneratorIdError({duplicateIds: ['payloads', 'models']}); + + expect(error.type).toBe(ErrorType.DUPLICATE_GENERATOR_ID); + expect(error.message).toContain('payloads'); + expect(error.message).toContain('models'); + expect(error.help).toContain('unique'); + }); + }); + + describe('createUnsupportedPresetForInputError', () => { + it('should create error with preset, input type, and supported presets', () => { + const error = createUnsupportedPresetForInputError({ + preset: 'channels', + inputType: 'jsonschema', + supportedPresets: ['payloads', 'models', 'types'] + }); + + expect(error.type).toBe(ErrorType.UNSUPPORTED_PRESET_FOR_INPUT); + expect(error.message).toContain('channels'); + expect(error.message).toContain('jsonschema'); + expect(error.details).toContain('payloads'); + expect(error.details).toContain('models'); + expect(error.details).toContain('types'); + expect(error.help).toContain('supported preset'); + }); + }); + describe('parseZodErrors', () => { it('should parse actual Zod validation errors', () => { // Create a real Zod schema and get actual validation error @@ -191,14 +315,14 @@ describe('Error Handling', () => { // Verify that validation failed expect(result.success).toBe(false); - + // Type guard to ensure we have the error if (result.success) { throw new Error('Expected validation to fail'); } - + const errors = parseZodErrors(result.error); - + // Should parse all validation errors expect(errors.length).toBeGreaterThan(0); expect(errors.some(e => e.includes('inputType'))).toBe(true); @@ -279,58 +403,4 @@ describe('Error Handling', () => { expect(errors[0]).toContain('Custom validation failed'); }); }); - - describe('enhanceError', () => { - it('should return CodegenError as-is', () => { - const error = createConfigNotFoundError('/path/to/config.json'); - const enhanced = enhanceError(error); - - expect(enhanced).toBe(error); - }); - - it('should detect config not found errors', () => { - const error = new Error('Cannot find configuration at path: /test/path'); - const enhanced = enhanceError(error); - - expect(enhanced.type).toBe(ErrorType.CONFIG_NOT_FOUND); - }); - - it('should detect config not found without path', () => { - const error = new Error('Cannot find configuration file. Searched in...'); - const enhanced = enhanceError(error); - - expect(enhanced.type).toBe(ErrorType.CONFIG_NOT_FOUND); - }); - - it('should detect invalid preset errors', () => { - const error = new Error('Unable to determine default generator'); - const enhanced = enhanceError(error); - - expect(enhanced.type).toBe(ErrorType.INVALID_PRESET); - }); - - it('should detect validation errors', () => { - const error = new Error('Invalid configuration file; validation error details'); - const enhanced = enhanceError(error); - - expect(enhanced.type).toBe(ErrorType.CONFIG_VALIDATION_ERROR); - }); - - it('should wrap unknown errors', () => { - const error = new Error('Some random error'); - const enhanced = enhanceError(error); - - expect(enhanced.type).toBe(ErrorType.UNKNOWN_ERROR); - expect(enhanced.message).toContain('Some random error'); - }); - - it('should handle non-Error objects', () => { - const error = 'String error'; - const enhanced = enhanceError(error); - - expect(enhanced.type).toBe(ErrorType.UNKNOWN_ERROR); - expect(enhanced.message).toBe('String error'); - }); - }); }); - diff --git a/test/codegen/inputs/jsonschema.test.ts b/test/codegen/inputs/jsonschema.test.ts index d14f20d1..add2a253 100644 --- a/test/codegen/inputs/jsonschema.test.ts +++ b/test/codegen/inputs/jsonschema.test.ts @@ -22,7 +22,7 @@ describe('JSON Schema Input Processing', () => { test('should throw error for invalid schema', () => { expect(() => { loadJsonSchemaFromMemory(null as any); - }).toThrow('Invalid JSON Schema document from memory: Document must be an object'); + }).toThrow('Failed to load jsonschema document: memory'); }); test('should warn for empty schema but not throw', () => { @@ -60,17 +60,17 @@ describe('JSON Schema Input Processing', () => { test('should throw error for non-existent file', async () => { await expect( loadJsonSchemaDocument('/non/existent/file.json') - ).rejects.toThrow('Failed to load JSON Schema document'); + ).rejects.toThrow('Failed to load jsonschema document'); }); test('should throw error for unsupported file format', async () => { const txtPath = path.join(os.tmpdir(), 'test-schema.txt'); fs.writeFileSync(txtPath, 'not a schema'); - + try { await expect( loadJsonSchemaDocument(txtPath) - ).rejects.toThrow('Unsupported file format for JSON Schema'); + ).rejects.toThrow('Failed to load jsonschema document'); } finally { if (fs.existsSync(txtPath)) { fs.unlinkSync(txtPath); diff --git a/test/codegen/renderer.spec.ts b/test/codegen/renderer.spec.ts index 78550566..68616e5b 100644 --- a/test/codegen/renderer.spec.ts +++ b/test/codegen/renderer.spec.ts @@ -103,7 +103,7 @@ describe('Render graph', () => { const graph = renderer.determineRenderGraph(context); await expect(async () => { await renderer.renderGraph(context, graph); - }).rejects.toThrow("You are not allowed to have circular dependencies in generators"); + }).rejects.toThrow("Circular dependency detected in generator configuration"); }); it('should throw error on self graph', async () => { const context: RunGeneratorContext = { @@ -119,13 +119,13 @@ describe('Render graph', () => { dependencies: ['custom'] } ] - }, + }, documentPath: 'test', configFilePath: '', asyncapiDocument: undefined }; - expect(() => renderer.determineRenderGraph(context)).toThrow("You are not allowed to have self dependant generators"); + expect(() => renderer.determineRenderGraph(context)).toThrow("Circular dependency detected in generator configuration"); }); it('should throw error when two generators has the same id', async () => { const context: any = { @@ -148,12 +148,12 @@ describe('Render graph', () => { language: 'typescript', } ] - }, + }, documentPath: 'test', configFilePath: '', asyncapiDocument: undefined }; - expect(() => renderer.determineRenderGraph(context)).toThrow('There are two or more generators that use the same id, please use unique id\'s for each generator, id(\'s) are payloads-typescript'); + expect(() => renderer.determineRenderGraph(context)).toThrow('Duplicate generator IDs found: payloads-typescript'); }); });