From b4ad66340def109efbe2511ed2b7b23ffe37c40d Mon Sep 17 00:00:00 2001 From: Hiroaki Mitsuyoshi Date: Fri, 29 Aug 2025 00:24:21 +0900 Subject: [PATCH 1/6] feat: add filter search --- src/cli.ts | 13 ++++------ src/cli/config.ts | 7 ++--- src/cli/errors/errorHandler.ts | 18 ++++++++----- src/cli/handlers/mainHandler.ts | 46 ++++++++++++++++++++++++--------- src/cli/parser/parser.ts | 42 +++++++++++++++++++++++++++++- src/cli/parser/types.ts | 3 ++- src/constants/messages.ts | 4 ++- src/core/filter.ts | 36 ++++++++++++++++++++++---- src/types/environment.ts | 4 +++ 9 files changed, 135 insertions(+), 38 deletions(-) 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/handlers/mainHandler.ts b/src/cli/handlers/mainHandler.ts index 6504c1d..9d631e5 100644 --- a/src/cli/handlers/mainHandler.ts +++ b/src/cli/handlers/mainHandler.ts @@ -2,29 +2,42 @@ 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'; /** - * 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) { + console.error('Error: Cannot use prefix filter and --filter option together'); + console.error('Usage:'); + console.error(' envui [PREFIX] # Filter by prefix'); + console.error(' envui --filter TEXT # Filter by partial match'); + process.exit(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 }; + } } - 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(); @@ -35,14 +48,23 @@ export function handleMainCommand(args?: ReadonlyArray): void { } // 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') { + if (filterConfig.type !== 'none') { // Show filter info even when no matches console.log(filterResult.filterInfo); } diff --git a/src/cli/parser/parser.ts b/src/cli/parser/parser.ts index 6ceb8b5..62e6df2 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 = nextToken.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..5f0d48f 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.", 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'; }; From 86f18b06c13b1f257bbef53bb4c41d031779e668 Mon Sep 17 00:00:00 2001 From: Hiroaki Mitsuyoshi Date: Fri, 29 Aug 2025 01:20:24 +0900 Subject: [PATCH 2/6] refactor: replace logger --- src/cli/errors/handlers.ts | 20 +++-- src/cli/handlers/mainHandler.ts | 21 ++--- src/constants/messages.ts | 2 +- src/utils/logger.ts | 16 ++++ tests/unit/cli/cli.test.ts | 71 ++++++++++++---- tests/unit/cli/cliMessages.test.ts | 2 +- tests/unit/cli/config.test.ts | 3 +- tests/unit/cli/errorHandler.test.ts | 45 +++++++++-- tests/unit/cli/errors.test.ts | 50 ++++++++---- tests/unit/cli/parser.test.ts | 72 ++++++++++++++++- tests/unit/core/filter.test.ts | 120 ++++++++++++++++++++++++++++ tests/unit/utils/logger.test.ts | 44 ++++++++++ tests/utils/testHelpers.ts | 9 +++ 13 files changed, 417 insertions(+), 58 deletions(-) diff --git a/src/cli/errors/handlers.ts b/src/cli/errors/handlers.ts index 95135c5..f66ae3f 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: @@ -26,11 +32,11 @@ export function handleCliError(error: CliErrorObject): void { 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 +46,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/handlers/mainHandler.ts b/src/cli/handlers/mainHandler.ts index 9d631e5..68645c9 100644 --- a/src/cli/handlers/mainHandler.ts +++ b/src/cli/handlers/mainHandler.ts @@ -4,6 +4,7 @@ import { filterEnvironmentVariables, generateNoMatchMessage } from '../../core/f 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'; /** * Create filter configuration from parsed arguments @@ -13,10 +14,10 @@ function createFilterConfig(parsedArgs: ParsedArgs): FilterConfig { if (parsedArgs.filterValue) { // Check for conflict with prefix filter if (parsedArgs.arguments.length > 0) { - console.error('Error: Cannot use prefix filter and --filter option together'); - console.error('Usage:'); - console.error(' envui [PREFIX] # Filter by prefix'); - console.error(' envui --filter TEXT # Filter by partial match'); + logger.userError('cannot use prefix filter and --filter option together', { + usage: + ' envui [PREFIX] # Filter by prefix\n envui --filter TEXT # Filter by partial match', + }); process.exit(EXIT_CODES.INVALID_ARGUMENT); } @@ -43,7 +44,7 @@ export function handleMainCommand(args?: ReadonlyArray, parsedArgs?: Par // Handle case when no environment variables found if (environmentData.length === 0) { - console.log(ERROR_MESSAGES.NO_ENVIRONMENT_VARIABLES); + logger.userInfo(ERROR_MESSAGES.NO_ENVIRONMENT_VARIABLES); process.exit(EXIT_CODES.DATA_NOT_FOUND); } @@ -66,20 +67,20 @@ export function handleMainCommand(args?: ReadonlyArray, parsedArgs?: Par if (filterResult.matchCount === 0) { if (filterConfig.type !== 'none') { // Show filter info even when no matches - console.log(filterResult.filterInfo); + logger.userInfo(filterResult.filterInfo); } - console.log(generateNoMatchMessage(filterConfig)); + logger.userInfo(generateNoMatchMessage(filterConfig)); process.exit(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); + logger.userInfo(table); process.exit(EXIT_CODES.SUCCESS); } diff --git a/src/constants/messages.ts b/src/constants/messages.ts index 5f0d48f..d29ec8f 100644 --- a/src/constants/messages.ts +++ b/src/constants/messages.ts @@ -6,7 +6,7 @@ export const CLI_MESSAGES = { "\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) => diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 0a5aad1..32b31e9 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,4 +1,5 @@ export const logger = { + // === 開発・デバッグ用ログ === debug: (message: string): void => { console.log(`[DEBUG] ${message}`); }, @@ -14,4 +15,19 @@ export const logger = { error: (message: string): void => { console.error(`[ERROR] ${message}`); }, + + // === ユーザー向け出力 === + 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..1db6335 100644 --- a/tests/unit/cli/cli.test.ts +++ b/tests/unit/cli/cli.test.ts @@ -5,10 +5,12 @@ import { createConsoleLogSpy, createConsoleErrorSpy, createLoggerErrorMock, + createLoggerUserErrorMock, type ProcessExitMock, type ConsoleLogSpy, type ConsoleErrorSpy, type LoggerErrorMock, + type LoggerUserErrorMock, } from '../../utils/testHelpers.js'; describe('CLI', () => { @@ -17,6 +19,8 @@ describe('CLI', () => { let processExitMock: ProcessExitMock; let processArgv: string[]; let loggerErrorMock: LoggerErrorMock; + let loggerUserErrorMock: LoggerUserErrorMock; + let loggerUserInfoMock: ReturnType; beforeEach(() => { // Reset modules before each test @@ -30,6 +34,8 @@ describe('CLI', () => { consoleErrorSpy = createConsoleErrorSpy(); processExitMock = createProcessExitMock(); loggerErrorMock = createLoggerErrorMock(); + loggerUserErrorMock = createLoggerUserErrorMock(); + loggerUserInfoMock = vi.fn(); }); afterEach(() => { @@ -56,14 +62,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 +91,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 +124,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 +157,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 @@ -166,7 +188,11 @@ describe('CLI', () => { })); vi.doMock('../../../src/utils/logger.js', () => ({ - logger: { error: vi.fn() }, + logger: { + error: vi.fn(), + userError: vi.fn(), + userInfo: vi.fn(), + }, })); // Import and execute CLI @@ -195,7 +221,11 @@ describe('CLI', () => { })); vi.doMock('../../../src/utils/logger.js', () => ({ - logger: { error: vi.fn() }, + logger: { + error: vi.fn(), + userError: vi.fn(), + userInfo: vi.fn(), + }, })); // Import and execute CLI @@ -222,7 +252,11 @@ describe('CLI', () => { })); vi.doMock('../../../src/utils/logger.js', () => ({ - logger: { error: vi.fn() }, + logger: { + error: vi.fn(), + userError: vi.fn(), + userInfo: vi.fn(), + }, })); // Import and execute CLI @@ -247,7 +281,11 @@ describe('CLI', () => { })); vi.doMock('../../../src/utils/logger.js', () => ({ - logger: { error: vi.fn() }, + logger: { + error: vi.fn(), + userError: vi.fn(), + userInfo: vi.fn(), + }, })); // Import and execute CLI @@ -274,17 +312,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..21551ac 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,76 @@ 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 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>(); From 387c89ac8ae80014446d906f8a04cc3f45bd6364 Mon Sep 17 00:00:00 2001 From: Hiroaki Mitsuyoshi Date: Fri, 29 Aug 2025 01:24:20 +0900 Subject: [PATCH 3/6] docs: update README --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 From 183db578426557380a2a28e682c5104383776fd1 Mon Sep 17 00:00:00 2001 From: Hiroaki Mitsuyoshi Date: Fri, 29 Aug 2025 01:27:35 +0900 Subject: [PATCH 4/6] fix --- src/utils/logger.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 32b31e9..b06a7fc 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,5 +1,5 @@ export const logger = { - // === 開発・デバッグ用ログ === + // === Development and debug logging === debug: (message: string): void => { console.log(`[DEBUG] ${message}`); }, @@ -16,7 +16,7 @@ export const logger = { console.error(`[ERROR] ${message}`); }, - // === ユーザー向け出力 === + // === User-facing output === userError: (message: string, options?: { usage?: string; hint?: string }): void => { console.error(`Error: ${message}`); if (options?.usage) { From c9bf56b72059d442c0661294f8f10b121ccc83e4 Mon Sep 17 00:00:00 2001 From: Hiroaki Mitsuyoshi Date: Sat, 30 Aug 2025 01:56:52 +0900 Subject: [PATCH 5/6] fix trim --- src/cli/handlers/mainHandler.ts | 2 +- src/cli/parser/parser.ts | 2 +- tests/unit/cli/parser.test.ts | 9 +++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/cli/handlers/mainHandler.ts b/src/cli/handlers/mainHandler.ts index 68645c9..04bbba3 100644 --- a/src/cli/handlers/mainHandler.ts +++ b/src/cli/handlers/mainHandler.ts @@ -28,7 +28,7 @@ function createFilterConfig(parsedArgs: ParsedArgs): FilterConfig { if (parsedArgs.arguments.length > 0) { const prefix = parsedArgs.arguments[0]; if (prefix && prefix.trim() !== '') { - return { type: 'prefix', value: prefix }; + return { type: 'prefix', value: prefix.trim() }; } } diff --git a/src/cli/parser/parser.ts b/src/cli/parser/parser.ts index 62e6df2..b0cd149 100644 --- a/src/cli/parser/parser.ts +++ b/src/cli/parser/parser.ts @@ -105,7 +105,7 @@ export function parseArgs(args: readonly string[], config: CliConfig): ParseResu }; } - filterValue = nextToken.value; + filterValue = value; skipNext = true; } diff --git a/tests/unit/cli/parser.test.ts b/tests/unit/cli/parser.test.ts index 21551ac..82ebd0f 100644 --- a/tests/unit/cli/parser.test.ts +++ b/tests/unit/cli/parser.test.ts @@ -205,6 +205,15 @@ describe('CLI Parser', () => { } }); + 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); From 8afb1bf436a810c1c984eae358f4ddabcf0556cc Mon Sep 17 00:00:00 2001 From: Hiroaki Mitsuyoshi Date: Sat, 30 Aug 2025 02:33:42 +0900 Subject: [PATCH 6/6] refactor: error handler --- src/cli/errors/handlers.ts | 15 +++++++++++ src/cli/errors/types.ts | 3 +++ src/cli/handlers/helpHandler.ts | 4 +-- src/cli/handlers/mainHandler.ts | 30 +++++++++++----------- src/cli/handlers/versionHandler.ts | 4 +-- tests/unit/cli/cli.test.ts | 40 ++++++++++++++++-------------- 6 files changed, 60 insertions(+), 36 deletions(-) diff --git a/src/cli/errors/handlers.ts b/src/cli/errors/handlers.ts index f66ae3f..684cabd 100644 --- a/src/cli/errors/handlers.ts +++ b/src/cli/errors/handlers.ts @@ -31,6 +31,21 @@ 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.userError(error.message); break; 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 04bbba3..9ef5885 100644 --- a/src/cli/handlers/mainHandler.ts +++ b/src/cli/handlers/mainHandler.ts @@ -5,6 +5,7 @@ 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 parsed arguments @@ -14,11 +15,11 @@ function createFilterConfig(parsedArgs: ParsedArgs): FilterConfig { if (parsedArgs.filterValue) { // Check for conflict with prefix filter if (parsedArgs.arguments.length > 0) { - logger.userError('cannot use prefix filter and --filter option together', { - usage: - ' envui [PREFIX] # Filter by prefix\n envui --filter TEXT # Filter by partial match', - }); - process.exit(EXIT_CODES.INVALID_ARGUMENT); + throw createCliError( + CliErrorType.FILTER_CONFLICT, + 'cannot use prefix filter and --filter option together', + EXIT_CODES.INVALID_ARGUMENT, + ); } return { type: 'partial', value: parsedArgs.filterValue }; @@ -44,8 +45,11 @@ export function handleMainCommand(args?: ReadonlyArray, parsedArgs?: Par // Handle case when no environment variables found if (environmentData.length === 0) { - logger.userInfo(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 @@ -65,12 +69,11 @@ export function handleMainCommand(args?: ReadonlyArray, parsedArgs?: Par // Handle no matches if (filterResult.matchCount === 0) { - if (filterConfig.type !== 'none') { - // Show filter info even when no matches - logger.userInfo(filterResult.filterInfo); + let message = generateNoMatchMessage(filterConfig); + if (filterConfig.type !== 'none' && filterResult.filterInfo) { + message = `${filterResult.filterInfo}\n${message}`; } - logger.userInfo(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 @@ -81,6 +84,5 @@ export function handleMainCommand(args?: ReadonlyArray, parsedArgs?: Par // Display table const table = createEnvironmentTable(filterResult.filtered); - logger.userInfo(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/tests/unit/cli/cli.test.ts b/tests/unit/cli/cli.test.ts index 1db6335..7fd13d5 100644 --- a/tests/unit/cli/cli.test.ts +++ b/tests/unit/cli/cli.test.ts @@ -2,19 +2,16 @@ 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[]; @@ -30,7 +27,6 @@ describe('CLI', () => { processArgv = process.argv; // Setup spies - consoleLogSpy = createConsoleLogSpy(); consoleErrorSpy = createConsoleErrorSpy(); processExitMock = createProcessExitMock(); loggerErrorMock = createLoggerErrorMock(); @@ -178,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(), @@ -191,16 +189,16 @@ describe('CLI', () => { logger: { error: vi.fn(), userError: vi.fn(), - userInfo: 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'); @@ -211,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(), @@ -224,16 +224,16 @@ describe('CLI', () => { logger: { error: vi.fn(), userError: vi.fn(), - userInfo: 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); }); @@ -242,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(), @@ -255,15 +257,15 @@ describe('CLI', () => { logger: { error: vi.fn(), userError: vi.fn(), - userInfo: 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); }); @@ -271,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(), @@ -284,15 +288,15 @@ describe('CLI', () => { logger: { error: vi.fn(), userError: vi.fn(), - userInfo: 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); }); });