diff --git a/README.md b/README.md index 4011a77..6eb8efb 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,13 @@ pnpm build # Display all environment variables ./dist/cli.js -# Filter by prefix (case-insensitive) +# Prefix filtering (case-insensitive) ./dist/cli.js VITE_ ./dist/cli.js NODE_ -./dist/cli.js PATH + +# Filter by partial match (case-insensitive) +./dist/cli.js --filter API +./dist/cli.js --filter database # Display help ./dist/cli.js --help diff --git a/src/cli.ts b/src/cli.ts index d2bfc3d..ea82e5b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { parseProcessArgs } from './cli/parser/index.js'; +import type { ParsedArgs } from './cli/parser/types.js'; import { handleParseError, handleSystemError } from './cli/errors/index.js'; import { createCliConfig } from './cli/config.js'; import { @@ -12,18 +13,14 @@ import { /** * Execute command based on parsed arguments */ -function executeCommand( - command: string, - args: ReadonlyArray, - config: ReturnType, -): void { +function executeCommand(parsedArgs: ParsedArgs, config: ReturnType): void { const commandHandlers: Record void> = { help: () => handleHelpCommand(config.helpText), version: () => handleVersionCommand(), - main: () => handleMainCommand(args), + main: () => handleMainCommand(parsedArgs.arguments, parsedArgs), }; - const handler = commandHandlers[command]; + const handler = commandHandlers[parsedArgs.command]; if (handler) { handler(); } else { @@ -45,7 +42,7 @@ function main(): void { return; } - executeCommand(parseResult.data.command, parseResult.data.arguments, config); + executeCommand(parseResult.data, config); } catch (error) { handleSystemError(error); } diff --git a/src/cli/config.ts b/src/cli/config.ts index 9027dbc..b1a95b1 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -11,7 +11,7 @@ export function createCliConfig(): CliConfig { version: pkg.version, description: 'Beautiful environment variable viewer', helpText: formatHelpText(), - supportedOptions: ['help', 'version'], + supportedOptions: ['help', 'version', 'filter'], }; } @@ -28,8 +28,9 @@ function formatHelpText(): string { ' envui [options]', '', 'Options:', - ' -h, --help display help for command', - ' -v, --version display version number', + ' -h, --help display help for command', + ' -v, --version display version number', + ' --filter TEXT filter variables containing TEXT (case-insensitive)', '', CLI_MESSAGES.HELP_EXAMPLES, CLI_MESSAGES.HELP_DESCRIPTION, diff --git a/src/cli/errors/errorHandler.ts b/src/cli/errors/errorHandler.ts index 186d8ed..5910c40 100644 --- a/src/cli/errors/errorHandler.ts +++ b/src/cli/errors/errorHandler.ts @@ -10,13 +10,17 @@ export function handleParseError(parseError: { message: string; code: number; }): void { - const error = createCliError( - parseError.type === 'unknown_option' - ? CliErrorType.UNKNOWN_OPTION - : CliErrorType.INVALID_ARGUMENT, - parseError.message, - parseError.code, - ); + let errorType: CliErrorType; + + if (parseError.type === 'unknown_option') { + errorType = CliErrorType.UNKNOWN_OPTION; + } else if (parseError.type === 'filter_requires_value') { + errorType = CliErrorType.INVALID_ARGUMENT; + } else { + errorType = CliErrorType.INVALID_ARGUMENT; + } + + const error = createCliError(errorType, parseError.message, parseError.code); handleCliError(error); process.exit(error.exitCode); } diff --git a/src/cli/errors/handlers.ts b/src/cli/errors/handlers.ts index 95135c5..684cabd 100644 --- a/src/cli/errors/handlers.ts +++ b/src/cli/errors/handlers.ts @@ -8,13 +8,19 @@ import { logger } from '../../utils/logger.js'; export function handleCliError(error: CliErrorObject): void { switch (error.type) { case CliErrorType.UNKNOWN_OPTION: - logger.error(error.message); - logger.error(CLI_MESSAGES.INVALID_OPTION_HELP); + logger.userError(error.message, { hint: CLI_MESSAGES.INVALID_OPTION_HELP }); break; case CliErrorType.INVALID_ARGUMENT: - logger.error(error.message); - logger.error(CLI_MESSAGES.INVALID_OPTION_HELP); + // For --filter errors, show detailed usage; for others, show hint + if (error.message.includes('--filter')) { + logger.userError(error.message, { + usage: + ' envui [PREFIX] # Filter by prefix\n envui --filter TEXT # Filter by partial match', + }); + } else { + logger.userError(error.message, { hint: CLI_MESSAGES.INVALID_OPTION_HELP }); + } break; case CliErrorType.HELP_REQUESTED: @@ -25,12 +31,27 @@ export function handleCliError(error: CliErrorObject): void { // Version display handled in main logic break; + case CliErrorType.FILTER_CONFLICT: + logger.userError(error.message, { + usage: + ' envui [PREFIX] # Filter by prefix\n envui --filter TEXT # Filter by partial match', + }); + break; + + case CliErrorType.NO_DATA_FOUND: + logger.userInfo(error.message); + break; + + case CliErrorType.SUCCESS_EXIT: + logger.userInfo(error.message); + break; + case CliErrorType.SYSTEM_ERROR: - logger.error(error.message); + logger.userError(error.message); break; default: - logger.error('An unexpected error occurred'); + logger.userError('An unexpected error occurred'); } } @@ -40,7 +61,7 @@ export function handleCliError(error: CliErrorObject): void { export function createUnknownOptionError(option: string): CliErrorObject { return createCliError( CliErrorType.UNKNOWN_OPTION, - `error: unknown option '${option}'`, + `unknown option '${option}'`, EXIT_CODES.INVALID_ARGUMENT, ); } diff --git a/src/cli/errors/types.ts b/src/cli/errors/types.ts index 012b37f..915d557 100644 --- a/src/cli/errors/types.ts +++ b/src/cli/errors/types.ts @@ -7,6 +7,9 @@ export enum CliErrorType { HELP_REQUESTED = 'help_requested', VERSION_REQUESTED = 'version_requested', SYSTEM_ERROR = 'system_error', + FILTER_CONFLICT = 'filter_conflict', + NO_DATA_FOUND = 'no_data_found', + SUCCESS_EXIT = 'success_exit', } /** diff --git a/src/cli/handlers/helpHandler.ts b/src/cli/handlers/helpHandler.ts index 7239b29..71aba02 100644 --- a/src/cli/handlers/helpHandler.ts +++ b/src/cli/handlers/helpHandler.ts @@ -1,9 +1,9 @@ import { EXIT_CODES } from '../../constants/index.js'; +import { createCliError, CliErrorType } from '../errors/types.js'; /** * Handle help command */ export function handleHelpCommand(helpText: string): void { - console.log(helpText); - process.exit(EXIT_CODES.SUCCESS); + throw createCliError(CliErrorType.SUCCESS_EXIT, helpText, EXIT_CODES.SUCCESS); } diff --git a/src/cli/handlers/mainHandler.ts b/src/cli/handlers/mainHandler.ts index 6504c1d..9ef5885 100644 --- a/src/cli/handlers/mainHandler.ts +++ b/src/cli/handlers/mainHandler.ts @@ -2,62 +2,87 @@ import { createEnvironmentTable } from '../../core/table.js'; import { readEnvironmentVariables } from '../../core/envReader.js'; import { filterEnvironmentVariables, generateNoMatchMessage } from '../../core/filter.js'; import type { FilterConfig } from '../../types/environment.js'; +import type { ParsedArgs } from '../parser/types.js'; import { EXIT_CODES, ERROR_MESSAGES } from '../../constants/index.js'; +import { logger } from '../../utils/logger.js'; +import { createCliError, CliErrorType } from '../errors/types.js'; /** - * Create filter configuration from arguments + * Create filter configuration from parsed arguments */ -function createFilterConfig(args?: ReadonlyArray): FilterConfig { - if (!args || args.length === 0) { - return { type: 'none' }; +function createFilterConfig(parsedArgs: ParsedArgs): FilterConfig { + // Check for --filter option + if (parsedArgs.filterValue) { + // Check for conflict with prefix filter + if (parsedArgs.arguments.length > 0) { + throw createCliError( + CliErrorType.FILTER_CONFLICT, + 'cannot use prefix filter and --filter option together', + EXIT_CODES.INVALID_ARGUMENT, + ); + } + + return { type: 'partial', value: parsedArgs.filterValue }; } - // Use the first argument as prefix - const prefix = args[0]; - if (!prefix || prefix.trim() === '') { - return { type: 'none' }; + // Check for prefix filter (first argument) + if (parsedArgs.arguments.length > 0) { + const prefix = parsedArgs.arguments[0]; + if (prefix && prefix.trim() !== '') { + return { type: 'prefix', value: prefix.trim() }; + } } - return { type: 'prefix', value: prefix }; + return { type: 'none' }; } /** * Handle main command (display environment variables) */ -export function handleMainCommand(args?: ReadonlyArray): void { +export function handleMainCommand(args?: ReadonlyArray, parsedArgs?: ParsedArgs): void { // Get environment variables const environmentData = readEnvironmentVariables(); // Handle case when no environment variables found if (environmentData.length === 0) { - console.log(ERROR_MESSAGES.NO_ENVIRONMENT_VARIABLES); - process.exit(EXIT_CODES.DATA_NOT_FOUND); + throw createCliError( + CliErrorType.NO_DATA_FOUND, + ERROR_MESSAGES.NO_ENVIRONMENT_VARIABLES, + EXIT_CODES.DATA_NOT_FOUND, + ); } // Create filter configuration - const filterConfig = createFilterConfig(args); + // For backward compatibility, create a minimal ParsedArgs if not provided + const effectiveParsedArgs = parsedArgs ?? { + command: 'main' as const, + options: [], + flags: new Set(), + arguments: args ?? [], + errors: [], + }; + + const filterConfig = createFilterConfig(effectiveParsedArgs); // Apply filter const filterResult = filterEnvironmentVariables(environmentData, filterConfig); // Handle no matches if (filterResult.matchCount === 0) { - if (filterConfig.type === 'prefix') { - // Show filter info even when no matches - console.log(filterResult.filterInfo); + let message = generateNoMatchMessage(filterConfig); + if (filterConfig.type !== 'none' && filterResult.filterInfo) { + message = `${filterResult.filterInfo}\n${message}`; } - console.log(generateNoMatchMessage(filterConfig)); - process.exit(EXIT_CODES.DATA_NOT_FOUND); + throw createCliError(CliErrorType.NO_DATA_FOUND, message, EXIT_CODES.DATA_NOT_FOUND); } // Display filter info if filter is applied if (filterResult.filterInfo) { - console.log(filterResult.filterInfo); - console.log(); // Empty line after filter info + logger.userInfo(filterResult.filterInfo); + logger.userInfo(''); // Empty line after filter info } // Display table const table = createEnvironmentTable(filterResult.filtered); - console.log(table); - process.exit(EXIT_CODES.SUCCESS); + throw createCliError(CliErrorType.SUCCESS_EXIT, table, EXIT_CODES.SUCCESS); } diff --git a/src/cli/handlers/versionHandler.ts b/src/cli/handlers/versionHandler.ts index a387849..d17550e 100644 --- a/src/cli/handlers/versionHandler.ts +++ b/src/cli/handlers/versionHandler.ts @@ -1,10 +1,10 @@ import { getVersionText } from '../config.js'; import { EXIT_CODES } from '../../constants/index.js'; +import { createCliError, CliErrorType } from '../errors/types.js'; /** * Handle version command */ export function handleVersionCommand(): void { - console.log(getVersionText()); - process.exit(EXIT_CODES.SUCCESS); + throw createCliError(CliErrorType.SUCCESS_EXIT, getVersionText(), EXIT_CODES.SUCCESS); } diff --git a/src/cli/parser/parser.ts b/src/cli/parser/parser.ts index 6ceb8b5..b0cd149 100644 --- a/src/cli/parser/parser.ts +++ b/src/cli/parser/parser.ts @@ -55,12 +55,22 @@ export function parseArgs(args: readonly string[], config: CliConfig): ParseResu const flags = new Set(); const parsedArguments: string[] = []; let command: ParsedArgs['command'] = 'main'; + let filterValue: string | undefined; // Check help flag with priority let hasHelp = false; let hasVersion = false; + let skipNext = false; + + for (let i = 0; i < tokens.length; i++) { + if (skipNext) { + skipNext = false; + continue; + } + + const token = tokens[i]; + if (!token) continue; - for (const token of tokens) { if (token.type === 'option') { const normalizedOption = OPTION_ALIASES[token.value] ?? token.value; @@ -68,6 +78,35 @@ export function parseArgs(args: readonly string[], config: CliConfig): ParseResu hasHelp = true; } else if (normalizedOption === 'version') { hasVersion = true; + } else if (normalizedOption === 'filter') { + // Handle --filter option with value + const nextToken = tokens[i + 1]; + if (!nextToken || nextToken.type !== 'argument') { + return { + success: false, + error: { + type: 'filter_requires_value', + message: '--filter option requires a search text', + code: 1, + }, + }; + } + + // Check if the value is empty or whitespace only + const value = nextToken.value.trim(); + if (value === '') { + return { + success: false, + error: { + type: 'filter_requires_value', + message: '--filter option requires a search text', + code: 1, + }, + }; + } + + filterValue = value; + skipNext = true; } options.push(normalizedOption); @@ -92,6 +131,7 @@ export function parseArgs(args: readonly string[], config: CliConfig): ParseResu flags, arguments: parsedArguments, errors: [], + filterValue, }, }; } diff --git a/src/cli/parser/types.ts b/src/cli/parser/types.ts index 49af6db..d647b66 100644 --- a/src/cli/parser/types.ts +++ b/src/cli/parser/types.ts @@ -2,7 +2,7 @@ * Type definition for CLI parsing error (for parser internal use) */ export interface ParseError { - readonly type: 'unknown_option' | 'invalid_argument' | 'system_error'; + readonly type: 'unknown_option' | 'invalid_argument' | 'system_error' | 'filter_requires_value'; readonly message: string; readonly code: number; } @@ -16,6 +16,7 @@ export interface ParsedArgs { readonly flags: ReadonlySet; readonly arguments: ReadonlyArray; readonly errors: ReadonlyArray; + readonly filterValue?: string; } /** diff --git a/src/constants/messages.ts b/src/constants/messages.ts index 48aaf20..d29ec8f 100644 --- a/src/constants/messages.ts +++ b/src/constants/messages.ts @@ -3,12 +3,14 @@ */ export const CLI_MESSAGES = { HELP_EXAMPLES: - '\nExamples:\n envui Display all environment variables in a beautiful table\n envui PREFIX Display environment variables starting with PREFIX', + "\nExamples:\n envui Display all environment variables\n envui PREFIX Display variables starting with PREFIX\n envui --filter API Display variables containing 'API'", HELP_DESCRIPTION: '\nDescription:\n envui is a modern alternative to printenv that displays environment \n variables in a clean, colorized table format for better readability.', - INVALID_OPTION_HELP: "\nUse 'envui --help' to see available options.", + INVALID_OPTION_HELP: "Try 'envui --help' for more information.", FILTER_INFO: (prefix: string, count: number, total: number) => `Filter: Variables starting with '${prefix}' (${count} of ${total} displayed)`, + FILTER_INFO_PARTIAL: (searchText: string, count: number, total: number) => + `Filter: Variables containing '${searchText}' (${count} of ${total} displayed)`, } as const; /** diff --git a/src/core/filter.ts b/src/core/filter.ts index 436e6e2..9f2dd23 100644 --- a/src/core/filter.ts +++ b/src/core/filter.ts @@ -14,6 +14,19 @@ export function createPrefixFilter(prefix: string): (env: EnvironmentData) => bo }; } +/** + * Creates a filter function that matches environment variables by partial match + * @param searchText - The text to search for (case-insensitive) + * @returns A filter function that checks if a variable contains the search text + */ +export function createPartialMatchFilter(searchText: string): (env: EnvironmentData) => boolean { + const lowerSearchText = searchText.toLowerCase(); + + return (env: EnvironmentData): boolean => { + return env.key.toLowerCase().includes(lowerSearchText); + }; +} + /** * Filters environment variables based on the provided configuration * @param data - Array of environment variables @@ -36,8 +49,16 @@ export function filterEnvironmentVariables( }; } - // Apply prefix filter (config.type is automatically narrowed to 'prefix') - const filter = createPrefixFilter(config.value); + // Apply appropriate filter based on type + let filter: (env: EnvironmentData) => boolean; + + if (config.type === 'prefix') { + filter = createPrefixFilter(config.value); + } else { + // config.type === 'partial' + filter = createPartialMatchFilter(config.value); + } + const filtered = data.filter(filter); return { @@ -65,8 +86,13 @@ export function generateFilterMessage(config: FilterConfig, result: FilterResult return ''; } - // Prefix filter message (config.type is automatically narrowed to 'prefix') - return CLI_MESSAGES.FILTER_INFO(config.value, result.matchCount, result.total); + // Generate appropriate message based on filter type + if (config.type === 'prefix') { + return CLI_MESSAGES.FILTER_INFO(config.value, result.matchCount, result.total); + } else { + // config.type === 'partial' + return CLI_MESSAGES.FILTER_INFO_PARTIAL(config.value, result.matchCount, result.total); + } } /** @@ -80,6 +106,6 @@ export function generateNoMatchMessage(config: FilterConfig): string { return ERROR_MESSAGES.NO_ENVIRONMENT_VARIABLES; } - // Specific message for prefix filter (config.type is automatically narrowed to 'prefix') + // Specific message for filter types return ERROR_MESSAGES.NO_MATCHING_VARIABLES(config.value); } diff --git a/src/types/environment.ts b/src/types/environment.ts index 3de7af2..695b20d 100644 --- a/src/types/environment.ts +++ b/src/types/environment.ts @@ -14,6 +14,10 @@ export type FilterConfig = readonly type: 'prefix'; readonly value: string; } + | { + readonly type: 'partial'; + readonly value: string; + } | { readonly type: 'none'; }; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 0a5aad1..b06a7fc 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,4 +1,5 @@ export const logger = { + // === Development and debug logging === debug: (message: string): void => { console.log(`[DEBUG] ${message}`); }, @@ -14,4 +15,19 @@ export const logger = { error: (message: string): void => { console.error(`[ERROR] ${message}`); }, + + // === User-facing output === + userError: (message: string, options?: { usage?: string; hint?: string }): void => { + console.error(`Error: ${message}`); + if (options?.usage) { + console.error('Usage:'); + console.error(options.usage); + } else if (options?.hint) { + console.error(options.hint); + } + }, + + userInfo: (message: string): void => { + console.log(message); + }, }; diff --git a/tests/unit/cli/cli.test.ts b/tests/unit/cli/cli.test.ts index 37d74f8..7fd13d5 100644 --- a/tests/unit/cli/cli.test.ts +++ b/tests/unit/cli/cli.test.ts @@ -2,21 +2,22 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createCliError, isCliError, CliErrorType } from '../../../src/cli/errors/index.js'; import { createProcessExitMock, - createConsoleLogSpy, createConsoleErrorSpy, createLoggerErrorMock, + createLoggerUserErrorMock, type ProcessExitMock, - type ConsoleLogSpy, type ConsoleErrorSpy, type LoggerErrorMock, + type LoggerUserErrorMock, } from '../../utils/testHelpers.js'; describe('CLI', () => { - let consoleLogSpy: ConsoleLogSpy; let consoleErrorSpy: ConsoleErrorSpy; let processExitMock: ProcessExitMock; let processArgv: string[]; let loggerErrorMock: LoggerErrorMock; + let loggerUserErrorMock: LoggerUserErrorMock; + let loggerUserInfoMock: ReturnType; beforeEach(() => { // Reset modules before each test @@ -26,10 +27,11 @@ describe('CLI', () => { processArgv = process.argv; // Setup spies - consoleLogSpy = createConsoleLogSpy(); consoleErrorSpy = createConsoleErrorSpy(); processExitMock = createProcessExitMock(); loggerErrorMock = createLoggerErrorMock(); + loggerUserErrorMock = createLoggerUserErrorMock(); + loggerUserInfoMock = vi.fn(); }); afterEach(() => { @@ -56,14 +58,18 @@ describe('CLI', () => { })); vi.doMock('../../../src/utils/logger.js', () => ({ - logger: { error: vi.fn() }, + logger: { + error: loggerErrorMock, + userError: loggerUserErrorMock, + userInfo: loggerUserInfoMock, + }, })); // Import and execute CLI await import('../../../src/cli.js'); // Verify behavior - expect(consoleLogSpy).toHaveBeenCalledWith('mocked table output'); + expect(loggerUserInfoMock).toHaveBeenCalledWith('mocked table output'); expect(processExitMock).toHaveBeenCalledWith(0); }); @@ -81,14 +87,18 @@ describe('CLI', () => { })); vi.doMock('../../../src/utils/logger.js', () => ({ - logger: { error: vi.fn() }, + logger: { + error: loggerErrorMock, + userError: loggerUserErrorMock, + userInfo: loggerUserInfoMock, + }, })); // Import and execute CLI await import('../../../src/cli.js'); // Verify behavior - expect(consoleLogSpy).toHaveBeenCalledWith('No environment variables found'); + expect(loggerUserInfoMock).toHaveBeenCalledWith('No environment variables found'); expect(processExitMock).toHaveBeenCalledWith(2); }); @@ -110,7 +120,11 @@ describe('CLI', () => { })); vi.doMock('../../../src/utils/logger.js', () => ({ - logger: { error: localLoggerErrorMock }, + logger: { + error: localLoggerErrorMock, + userError: vi.fn(), + userInfo: vi.fn(), + }, })); // Import and execute CLI @@ -139,7 +153,11 @@ describe('CLI', () => { })); vi.doMock('../../../src/utils/logger.js', () => ({ - logger: { error: localLoggerErrorMock }, + logger: { + error: localLoggerErrorMock, + userError: vi.fn(), + userInfo: vi.fn(), + }, })); // Import and execute CLI @@ -156,6 +174,8 @@ describe('CLI', () => { // Set argv with --help process.argv = ['node', 'cli.js', '--help']; + const localLoggerUserInfoMock = vi.fn(); + // Mock dependencies vi.doMock('../../../src/core/envReader.js', () => ({ readEnvironmentVariables: vi.fn(), @@ -166,15 +186,19 @@ describe('CLI', () => { })); vi.doMock('../../../src/utils/logger.js', () => ({ - logger: { error: vi.fn() }, + logger: { + error: vi.fn(), + userError: vi.fn(), + userInfo: localLoggerUserInfoMock, + }, })); // Import and execute CLI await import('../../../src/cli.js'); - // Verify help is displayed - expect(consoleLogSpy).toHaveBeenCalled(); - const helpOutput = consoleLogSpy.mock.calls[0][0]; + // Verify help is displayed through userInfo + expect(localLoggerUserInfoMock).toHaveBeenCalled(); + const helpOutput = localLoggerUserInfoMock.mock.calls[0][0]; expect(helpOutput).toContain('envui'); expect(helpOutput).toContain('Beautiful environment variable viewer'); expect(helpOutput).toContain('--help'); @@ -185,6 +209,8 @@ describe('CLI', () => { // Set argv with -h process.argv = ['node', 'cli.js', '-h']; + const localLoggerUserInfoMock = vi.fn(); + // Mock dependencies vi.doMock('../../../src/core/envReader.js', () => ({ readEnvironmentVariables: vi.fn(), @@ -195,15 +221,19 @@ describe('CLI', () => { })); vi.doMock('../../../src/utils/logger.js', () => ({ - logger: { error: vi.fn() }, + logger: { + error: vi.fn(), + userError: vi.fn(), + userInfo: localLoggerUserInfoMock, + }, })); // Import and execute CLI await import('../../../src/cli.js'); - // Verify help is displayed - expect(consoleLogSpy).toHaveBeenCalled(); - const helpOutput = consoleLogSpy.mock.calls[0][0]; + // Verify help is displayed through userInfo + expect(localLoggerUserInfoMock).toHaveBeenCalled(); + const helpOutput = localLoggerUserInfoMock.mock.calls[0][0]; expect(helpOutput).toContain('envui'); expect(processExitMock).toHaveBeenCalledWith(0); }); @@ -212,6 +242,8 @@ describe('CLI', () => { // Set argv with --version process.argv = ['node', 'cli.js', '--version']; + const localLoggerUserInfoMock = vi.fn(); + // Mock dependencies vi.doMock('../../../src/core/envReader.js', () => ({ readEnvironmentVariables: vi.fn(), @@ -222,14 +254,18 @@ describe('CLI', () => { })); vi.doMock('../../../src/utils/logger.js', () => ({ - logger: { error: vi.fn() }, + logger: { + error: vi.fn(), + userError: vi.fn(), + userInfo: localLoggerUserInfoMock, + }, })); // Import and execute CLI await import('../../../src/cli.js'); - // Verify version is displayed - expect(consoleLogSpy).toHaveBeenCalled(); + // Verify version is displayed through userInfo + expect(localLoggerUserInfoMock).toHaveBeenCalled(); expect(processExitMock).toHaveBeenCalledWith(0); }); @@ -237,6 +273,8 @@ describe('CLI', () => { // Set argv with -v process.argv = ['node', 'cli.js', '-v']; + const localLoggerUserInfoMock = vi.fn(); + // Mock dependencies vi.doMock('../../../src/core/envReader.js', () => ({ readEnvironmentVariables: vi.fn(), @@ -247,14 +285,18 @@ describe('CLI', () => { })); vi.doMock('../../../src/utils/logger.js', () => ({ - logger: { error: vi.fn() }, + logger: { + error: vi.fn(), + userError: vi.fn(), + userInfo: localLoggerUserInfoMock, + }, })); // Import and execute CLI await import('../../../src/cli.js'); - // Verify version is displayed - expect(consoleLogSpy).toHaveBeenCalled(); + // Verify version is displayed through userInfo + expect(localLoggerUserInfoMock).toHaveBeenCalled(); expect(processExitMock).toHaveBeenCalledWith(0); }); }); @@ -274,17 +316,20 @@ describe('CLI', () => { })); vi.doMock('../../../src/utils/logger.js', () => ({ - logger: { error: loggerErrorMock }, + logger: { + error: loggerErrorMock, + userError: loggerUserErrorMock, + userInfo: vi.fn(), + }, })); // Import CLI await import('../../../src/cli.js'); // Verify error handling - expect(loggerErrorMock).toHaveBeenCalledWith('Unknown option: --invalid'); - expect(loggerErrorMock).toHaveBeenCalledWith( - "\nUse 'envui --help' to see available options.", - ); + expect(loggerUserErrorMock).toHaveBeenCalledWith('Unknown option: --invalid', { + hint: "Try 'envui --help' for more information.", + }); expect(processExitMock).toHaveBeenCalledWith(2); }); diff --git a/tests/unit/cli/cliMessages.test.ts b/tests/unit/cli/cliMessages.test.ts index 31d7441..f351541 100644 --- a/tests/unit/cli/cliMessages.test.ts +++ b/tests/unit/cli/cliMessages.test.ts @@ -19,7 +19,7 @@ describe('CLI Messages', () => { it('should have INVALID_OPTION_HELP with help guidance', () => { expect(CLI_MESSAGES.INVALID_OPTION_HELP).toBeDefined(); expect(CLI_MESSAGES.INVALID_OPTION_HELP).toContain('envui --help'); - expect(CLI_MESSAGES.INVALID_OPTION_HELP).toContain('available options'); + expect(CLI_MESSAGES.INVALID_OPTION_HELP).toContain('more information'); }); }); }); diff --git a/tests/unit/cli/config.test.ts b/tests/unit/cli/config.test.ts index dcfe98e..2dc3f38 100644 --- a/tests/unit/cli/config.test.ts +++ b/tests/unit/cli/config.test.ts @@ -29,12 +29,13 @@ describe('CLI Config', () => { expect(config.helpText).toContain('Beautiful environment variable viewer'); expect(config.helpText).toContain('--help'); expect(config.helpText).toContain('--version'); + expect(config.helpText).toContain('--filter'); }); it('should define supported options', () => { const config = createCliConfig(); - expect(config.supportedOptions).toEqual(['help', 'version']); + expect(config.supportedOptions).toEqual(['help', 'version', 'filter']); }); }); diff --git a/tests/unit/cli/errorHandler.test.ts b/tests/unit/cli/errorHandler.test.ts index eb3fb15..54389ad 100644 --- a/tests/unit/cli/errorHandler.test.ts +++ b/tests/unit/cli/errorHandler.test.ts @@ -1,18 +1,28 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { CliErrorType, createCliError } from '../../../src/cli/errors/index.js'; -import { createProcessExitMock, type ProcessExitMock } from '../../utils/testHelpers.js'; +import { + createProcessExitMock, + createLoggerUserErrorMock, + type ProcessExitMock, + type LoggerUserErrorMock, +} from '../../utils/testHelpers.js'; describe('Error Handler', () => { let processExitMock: ProcessExitMock; let loggerErrorMock: ReturnType; + let loggerUserErrorMock: LoggerUserErrorMock; beforeEach(() => { vi.resetModules(); processExitMock = createProcessExitMock(); loggerErrorMock = vi.fn(); + loggerUserErrorMock = createLoggerUserErrorMock(); vi.doMock('../../../src/utils/logger.js', () => ({ - logger: { error: loggerErrorMock }, + logger: { + error: loggerErrorMock, + userError: loggerUserErrorMock, + }, })); }); @@ -23,23 +33,44 @@ describe('Error Handler', () => { describe('handleParseError', () => { it('should handle unknown option parse error', async () => { const { handleParseError } = await import('../../../src/cli/errors/errorHandler.js'); - const parseError = { type: 'unknown_option', message: 'Unknown option', code: 2 }; + const parseError = { type: 'unknown_option', message: "unknown option '--invalid'", code: 2 }; handleParseError(parseError); - expect(loggerErrorMock).toHaveBeenCalledWith('Unknown option'); + expect(loggerUserErrorMock).toHaveBeenCalledWith("unknown option '--invalid'", { + hint: "Try 'envui --help' for more information.", + }); expect(processExitMock).toHaveBeenCalledWith(2); }); - it('should handle invalid argument parse error', async () => { + it('should handle invalid argument parse error (non-filter)', async () => { const { handleParseError } = await import('../../../src/cli/errors/errorHandler.js'); const parseError = { type: 'invalid_argument', message: 'Invalid argument', code: 2 }; handleParseError(parseError); - expect(loggerErrorMock).toHaveBeenCalledWith('Invalid argument'); + expect(loggerUserErrorMock).toHaveBeenCalledWith('Invalid argument', { + hint: "Try 'envui --help' for more information.", + }); expect(processExitMock).toHaveBeenCalledWith(2); }); + + it('should handle invalid argument parse error (--filter)', async () => { + const { handleParseError } = await import('../../../src/cli/errors/errorHandler.js'); + const parseError = { + type: 'filter_requires_value', + message: '--filter option requires a search text', + code: 1, + }; + + handleParseError(parseError); + + expect(loggerUserErrorMock).toHaveBeenCalledWith('--filter option requires a search text', { + usage: + ' envui [PREFIX] # Filter by prefix\n envui --filter TEXT # Filter by partial match', + }); + expect(processExitMock).toHaveBeenCalledWith(1); + }); }); describe('handleSystemError', () => { @@ -49,7 +80,7 @@ describe('Error Handler', () => { handleSystemError(cliError); - expect(loggerErrorMock).toHaveBeenCalledWith('System error'); + expect(loggerUserErrorMock).toHaveBeenCalledWith('System error'); expect(processExitMock).toHaveBeenCalledWith(1); }); diff --git a/tests/unit/cli/errors.test.ts b/tests/unit/cli/errors.test.ts index c5c1511..3271705 100644 --- a/tests/unit/cli/errors.test.ts +++ b/tests/unit/cli/errors.test.ts @@ -2,8 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createConsoleErrorSpy, createLoggerErrorMock, + createLoggerUserErrorMock, type ConsoleErrorSpy, type LoggerErrorMock, + type LoggerUserErrorMock, } from '../../utils/testHelpers.js'; import { createCliError, @@ -17,13 +19,18 @@ import { describe('CLI Errors', () => { let consoleErrorSpy: ConsoleErrorSpy; let loggerErrorMock: LoggerErrorMock; + let loggerUserErrorMock: LoggerUserErrorMock; beforeEach(() => { consoleErrorSpy = createConsoleErrorSpy(); loggerErrorMock = createLoggerErrorMock(); + loggerUserErrorMock = createLoggerUserErrorMock(); vi.doMock('../../../src/utils/logger.js', () => ({ - logger: { error: loggerErrorMock }, + logger: { + error: loggerErrorMock, + userError: loggerUserErrorMock, + }, })); }); @@ -73,7 +80,7 @@ describe('CLI Errors', () => { const error = createUnknownOptionError('--invalid'); expect(error.type).toBe(CliErrorType.UNKNOWN_OPTION); - expect(error.message).toBe("error: unknown option '--invalid'"); + expect(error.message).toBe("unknown option '--invalid'"); expect(error.exitCode).toBe(2); }); }); @@ -91,26 +98,40 @@ describe('CLI Errors', () => { describe('handleCliError', () => { it('should handle unknown option error', async () => { const { handleCliError } = await import('../../../src/cli/errors/handlers.js'); - const error = createCliError(CliErrorType.UNKNOWN_OPTION, 'Unknown option', 2); + const error = createCliError(CliErrorType.UNKNOWN_OPTION, "unknown option '--invalid'", 2); handleCliError(error); - expect(loggerErrorMock).toHaveBeenCalledWith('Unknown option'); - expect(loggerErrorMock).toHaveBeenCalledWith( - "\nUse 'envui --help' to see available options.", - ); + expect(loggerUserErrorMock).toHaveBeenCalledWith("unknown option '--invalid'", { + hint: "Try 'envui --help' for more information.", + }); }); - it('should handle invalid argument error', async () => { + it('should handle invalid argument error (non-filter)', async () => { const { handleCliError } = await import('../../../src/cli/errors/handlers.js'); const error = createCliError(CliErrorType.INVALID_ARGUMENT, 'Invalid argument', 2); handleCliError(error); - expect(loggerErrorMock).toHaveBeenCalledWith('Invalid argument'); - expect(loggerErrorMock).toHaveBeenCalledWith( - "\nUse 'envui --help' to see available options.", + expect(loggerUserErrorMock).toHaveBeenCalledWith('Invalid argument', { + hint: "Try 'envui --help' for more information.", + }); + }); + + it('should handle invalid argument error (--filter)', async () => { + const { handleCliError } = await import('../../../src/cli/errors/handlers.js'); + const error = createCliError( + CliErrorType.INVALID_ARGUMENT, + '--filter option requires a search text', + 2, ); + + handleCliError(error); + + expect(loggerUserErrorMock).toHaveBeenCalledWith('--filter option requires a search text', { + usage: + ' envui [PREFIX] # Filter by prefix\n envui --filter TEXT # Filter by partial match', + }); }); it('should handle system error', async () => { @@ -119,8 +140,7 @@ describe('CLI Errors', () => { handleCliError(error); - // Since logger is mocked, we can't easily test it here - // The test would need to be structured differently + expect(loggerUserErrorMock).toHaveBeenCalledWith('System error'); }); it('should handle help requested (no output)', async () => { @@ -155,8 +175,8 @@ describe('CLI Errors', () => { handleCliError(unknownError); - // The default case should call logger.error with generic message - expect(loggerErrorMock).toHaveBeenCalledWith('An unexpected error occurred'); + // The default case should call logger.userError with generic message + expect(loggerUserErrorMock).toHaveBeenCalledWith('An unexpected error occurred'); }); }); }); diff --git a/tests/unit/cli/parser.test.ts b/tests/unit/cli/parser.test.ts index be90659..82ebd0f 100644 --- a/tests/unit/cli/parser.test.ts +++ b/tests/unit/cli/parser.test.ts @@ -13,7 +13,7 @@ describe('CLI Parser', () => { version: '0.1.0', description: 'Test description', helpText: 'Test help', - supportedOptions: ['help', 'version'], + supportedOptions: ['help', 'version', 'filter'], }; describe('tokenize', () => { @@ -162,6 +162,85 @@ describe('CLI Parser', () => { expect(result.data.command).toBe('help'); } }); + + it('should parse --filter option with value', () => { + const result = parseArgs(['node', 'cli.js', '--filter', 'API'], mockConfig); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.command).toBe('main'); + expect(result.data.flags.has('filter')).toBe(true); + expect(result.data.filterValue).toBe('API'); + expect(result.data.arguments).toEqual([]); + } + }); + + it('should handle --filter without value', () => { + const result = parseArgs(['node', 'cli.js', '--filter'], mockConfig); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.type).toBe('filter_requires_value'); + expect(result.error.message).toBe('--filter option requires a search text'); + } + }); + + it('should handle --filter with empty string value', () => { + const result = parseArgs(['node', 'cli.js', '--filter', ''], mockConfig); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.type).toBe('filter_requires_value'); + expect(result.error.message).toBe('--filter option requires a search text'); + } + }); + + it('should handle --filter with whitespace only value', () => { + const result = parseArgs(['node', 'cli.js', '--filter', ' '], mockConfig); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.type).toBe('filter_requires_value'); + expect(result.error.message).toBe('--filter option requires a search text'); + } + }); + + it('should trim whitespace from --filter value', () => { + const result = parseArgs(['node', 'cli.js', '--filter', ' API '], mockConfig); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.filterValue).toBe('API'); + } + }); + + it('should parse --filter with special characters', () => { + const result = parseArgs(['node', 'cli.js', '--filter', '_API_'], mockConfig); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.filterValue).toBe('_API_'); + } + }); + + it('should parse --filter with case-insensitive value', () => { + const result = parseArgs(['node', 'cli.js', '--filter', 'api'], mockConfig); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.filterValue).toBe('api'); + } + }); + + it('should not consume prefix argument when --filter is used', () => { + const result = parseArgs(['node', 'cli.js', '--filter', 'API', 'PREFIX'], mockConfig); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.filterValue).toBe('API'); + expect(result.data.arguments).toEqual(['PREFIX']); + } + }); }); describe('isValidOption', () => { diff --git a/tests/unit/core/filter.test.ts b/tests/unit/core/filter.test.ts index e56d31e..dbb56d0 100644 --- a/tests/unit/core/filter.test.ts +++ b/tests/unit/core/filter.test.ts @@ -3,6 +3,7 @@ import type { EnvironmentData } from '../../../src/types/environment.js'; import type { FilterConfig, FilterResult } from '../../../src/types/environment.js'; import { createPrefixFilter, + createPartialMatchFilter, filterEnvironmentVariables, generateFilterMessage, generateNoMatchMessage, @@ -193,6 +194,125 @@ describe('Prefix Filter', () => { }); }); + describe('Partial Match Filter', () => { + describe('createPartialMatchFilter', () => { + it('should create a filter function that matches partial text case-insensitively', () => { + const filter = createPartialMatchFilter('API'); + + expect(filter({ key: 'VITE_API_URL', value: 'test' })).toBe(true); + expect(filter({ key: 'API_KEY', value: 'test' })).toBe(true); + expect(filter({ key: 'MY_API_TOKEN', value: 'test' })).toBe(true); + expect(filter({ key: 'NODE_ENV', value: 'test' })).toBe(false); + }); + + it('should handle lowercase search input', () => { + const filter = createPartialMatchFilter('api'); + + expect(filter({ key: 'VITE_API_URL', value: 'test' })).toBe(true); + expect(filter({ key: 'API_KEY', value: 'test' })).toBe(true); + expect(filter({ key: 'MY_API_TOKEN', value: 'test' })).toBe(true); + }); + + it('should handle empty search text', () => { + const filter = createPartialMatchFilter(''); + + expect(filter({ key: 'VITE_API_URL', value: 'test' })).toBe(true); + expect(filter({ key: 'NODE_ENV', value: 'test' })).toBe(true); + expect(filter({ key: '', value: 'test' })).toBe(true); + }); + + it('should handle special characters in search text', () => { + const filter = createPartialMatchFilter('_API'); + + expect(filter({ key: 'VITE_API_URL', value: 'test' })).toBe(true); + expect(filter({ key: 'MY_API_KEY', value: 'test' })).toBe(true); + expect(filter({ key: 'API_TOKEN', value: 'test' })).toBe(false); + }); + + it('should handle spaces in search text', () => { + const filter = createPartialMatchFilter(' VAR'); + + expect(filter({ key: 'MY VAR_TEST', value: 'test' })).toBe(true); + expect(filter({ key: 'EMPTY_VAR', value: 'test' })).toBe(false); + }); + }); + + describe('filterEnvironmentVariables with partial match', () => { + it('should filter variables by partial match', () => { + const config: FilterConfig = { type: 'partial', value: 'API' }; + const result = filterEnvironmentVariables(mockEnvData, config); + + expect(result.filtered).toHaveLength(2); + expect(result.filtered.every((env) => env.key.includes('API'))).toBe(true); + expect(result.total).toBe(8); + expect(result.matchCount).toBe(2); + }); + + it('should handle case-insensitive partial match', () => { + const config: FilterConfig = { type: 'partial', value: 'env' }; + const result = filterEnvironmentVariables(mockEnvData, config); + + expect(result.filtered).toHaveLength(1); + expect(result.filtered[0]?.key).toBe('NODE_ENV'); + }); + + it('should return empty array when no partial matches found', () => { + const config: FilterConfig = { type: 'partial', value: 'MISSING' }; + const result = filterEnvironmentVariables(mockEnvData, config); + + expect(result.filtered).toHaveLength(0); + expect(result.total).toBe(8); + expect(result.matchCount).toBe(0); + }); + + it('should handle partial match at different positions', () => { + const config: FilterConfig = { type: 'partial', value: 'NODE' }; + const result = filterEnvironmentVariables(mockEnvData, config); + + expect(result.filtered).toHaveLength(2); + expect(result.filtered.some((env) => env.key === 'NODE_ENV')).toBe(true); + expect(result.filtered.some((env) => env.key === 'NODE_OPTIONS')).toBe(true); + }); + }); + + describe('generateFilterMessage with partial match', () => { + it('should generate correct filter info message for partial match filter', () => { + const config: FilterConfig = { type: 'partial', value: 'API' }; + const result: FilterResult = { + filtered: [], + total: 150, + matchCount: 5, + filterInfo: '', + }; + + const message = generateFilterMessage(config, result); + expect(message).toBe("Filter: Variables containing 'API' (5 of 150 displayed)"); + }); + + it('should handle 0 matches correctly for partial match', () => { + const config: FilterConfig = { type: 'partial', value: 'MISSING' }; + const result: FilterResult = { + filtered: [], + total: 150, + matchCount: 0, + filterInfo: '', + }; + + const message = generateFilterMessage(config, result); + expect(message).toBe("Filter: Variables containing 'MISSING' (0 of 150 displayed)"); + }); + }); + + describe('generateNoMatchMessage with partial match', () => { + it('should generate correct no match message for partial match filter', () => { + const config: FilterConfig = { type: 'partial', value: 'MISSING' }; + const message = generateNoMatchMessage(config); + + expect(message).toBe("No environment variables found matching 'MISSING'"); + }); + }); + }); + describe('Edge cases', () => { it('should handle empty environment data', () => { const config: FilterConfig = { type: 'prefix', value: 'VITE_' }; diff --git a/tests/unit/utils/logger.test.ts b/tests/unit/utils/logger.test.ts index f378ea5..de43cad 100644 --- a/tests/unit/utils/logger.test.ts +++ b/tests/unit/utils/logger.test.ts @@ -55,6 +55,50 @@ describe('logger', () => { }); }); + describe('userError', () => { + it('should output error message with Error: prefix', () => { + logger.userError('test error'); + + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith('Error: test error'); + }); + + it('should output error message with usage details', () => { + logger.userError('test error', { usage: 'usage details' }); + + expect(consoleErrorSpy).toHaveBeenCalledTimes(3); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, 'Error: test error'); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, 'Usage:'); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(3, 'usage details'); + }); + + it('should output error message with hint', () => { + logger.userError('test error', { hint: 'helpful hint' }); + + expect(consoleErrorSpy).toHaveBeenCalledTimes(2); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, 'Error: test error'); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, 'helpful hint'); + }); + + it('should prioritize usage over hint when both are provided', () => { + logger.userError('test error', { usage: 'usage details', hint: 'helpful hint' }); + + expect(consoleErrorSpy).toHaveBeenCalledTimes(3); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, 'Error: test error'); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, 'Usage:'); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(3, 'usage details'); + }); + }); + + describe('userInfo', () => { + it('should output info message without prefix', () => { + logger.userInfo('test info'); + + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledWith('test info'); + }); + }); + describe('all log levels', () => { it('should handle empty strings', () => { logger.debug(''); diff --git a/tests/utils/testHelpers.ts b/tests/utils/testHelpers.ts index f87edce..8e80896 100644 --- a/tests/utils/testHelpers.ts +++ b/tests/utils/testHelpers.ts @@ -21,6 +21,9 @@ export type ConsoleErrorSpy = MockInstance<[message?: any, ...optionalParams: an * Mock function types for better type safety */ export type LoggerErrorMock = MockedFunction<(message: string) => void>; +export type LoggerUserErrorMock = MockedFunction< + (message: string, options?: { usage?: string; hint?: string }) => void +>; /** * Creates a properly typed console.log spy @@ -36,3 +39,9 @@ export const createConsoleErrorSpy = () => vi.spyOn(console, 'error').mockImplem * Creates a properly typed logger error mock */ export const createLoggerErrorMock = () => vi.fn<[string], void>(); + +/** + * Creates a properly typed logger userError mock + */ +export const createLoggerUserErrorMock = () => + vi.fn<[string, { usage?: string; hint?: string }?], void>();