From 3bc35df63cf751757ca37069c6af627d57b9bb22 Mon Sep 17 00:00:00 2001 From: hypercross Date: Thu, 2 Apr 2026 08:58:11 +0800 Subject: [PATCH] feat: add command runner and registry --- src/index.ts | 3 + src/utils/command/command-registry.ts | 101 +++++++++++ src/utils/command/command-runner.ts | 17 ++ src/utils/command/index.ts | 14 +- tests/utils/command-runner.test.ts | 240 ++++++++++++++++++++++++++ 5 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 src/utils/command/command-registry.ts create mode 100644 src/utils/command/command-runner.ts create mode 100644 tests/utils/command-runner.test.ts diff --git a/src/index.ts b/src/index.ts index 73e31d4..c2cfc2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,9 @@ export { createRule, dispatchCommand } from './core/rule'; export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command'; export { parseCommand, parseCommandSchema, validateCommand, parseCommandWithSchema, applyCommandSchema } from './utils/command'; +export type { CommandRunner, CommandRunnerHandler, CommandRegistry, CommandRunnerContext } from './utils/command'; +export { createCommandRegistry, registerCommand, unregisterCommand, hasCommand, getCommand, runCommand, createCommandRunnerContext } from './utils/command'; + export type { Entity, EntityAccessor } from './utils/entity'; export { createEntityCollection } from './utils/entity'; diff --git a/src/utils/command/command-registry.ts b/src/utils/command/command-registry.ts new file mode 100644 index 0000000..3e751cf --- /dev/null +++ b/src/utils/command/command-registry.ts @@ -0,0 +1,101 @@ +import type { Command } from './types.js'; +import type { CommandRunner, CommandRunnerContext as RunnerContext } from './command-runner.js'; +import { parseCommand } from './command-parse.js'; +import { applyCommandSchema } from './command-apply.js'; + +export type CommandRegistry = Map>; + +export function createCommandRegistry(): CommandRegistry { + return new Map(); +} + +export function registerCommand( + registry: CommandRegistry, + runner: CommandRunner +): void { + registry.set(runner.schema.name, runner as CommandRunner); +} + +export function unregisterCommand( + registry: CommandRegistry, + name: string +): void { + registry.delete(name); +} + +export function hasCommand( + registry: CommandRegistry, + name: string +): boolean { + return registry.has(name); +} + +export function getCommand( + registry: CommandRegistry, + name: string +): CommandRunner | undefined { + return registry.get(name); +} + +async function executeWithRunnerContext( + registry: CommandRegistry, + context: TContext, + runner: CommandRunner, + command: Command +): Promise<{ success: true; result: unknown } | { success: false; error: string }> { + const runnerCtx: RunnerContext = { + context, + run: (input: string) => runCommand(registry, context, input), + runParsed: (cmd: Command) => runCommandParsed(registry, context, cmd), + }; + try { + const result = await runner.run.call(runnerCtx, command); + return { success: true, result }; + } catch (e) { + const error = e as Error; + return { success: false, error: error.message }; + } +} + +export async function runCommand( + registry: CommandRegistry, + context: TContext, + input: string +): Promise<{ success: true; result: unknown } | { success: false; error: string }> { + const command = parseCommand(input); + return await runCommandParsed(registry, context, command); +} + +export async function runCommandParsed( + registry: CommandRegistry, + context: TContext, + command: Command +): Promise<{ success: true; result: unknown } | { success: false; error: string }> { + const runner = registry.get(command.name); + if (!runner) { + return { success: false, error: `Unknown command: ${command.name}` }; + } + + const validationResult = applyCommandSchema(command, runner.schema); + if (!validationResult.valid) { + return { success: false, error: validationResult.errors.join('; ') }; + } + + return await executeWithRunnerContext(registry, context, runner, validationResult.command); +} + +export type CommandRunnerContext = RunnerContext & { + registry: CommandRegistry; +}; + +export function createCommandRunnerContext( + registry: CommandRegistry, + context: TContext +): CommandRunnerContext { + return { + registry, + context, + run: (input: string) => runCommand(registry, context, input), + runParsed: (command: Command) => runCommandParsed(registry, context, command), + }; +} diff --git a/src/utils/command/command-runner.ts b/src/utils/command/command-runner.ts new file mode 100644 index 0000000..eec7241 --- /dev/null +++ b/src/utils/command/command-runner.ts @@ -0,0 +1,17 @@ +import type { Command, CommandSchema } from './types.js'; + +export type CommandRunnerContext = { + context: TContext; + run: (input: string) => Promise<{ success: true; result: unknown } | { success: false; error: string }>; + runParsed: (command: Command) => Promise<{ success: true; result: unknown } | { success: false; error: string }>; +}; + +export type CommandRunnerHandler = ( + this: CommandRunnerContext, + command: Command +) => Promise; + +export type CommandRunner = { + schema: CommandSchema; + run: CommandRunnerHandler; +}; diff --git a/src/utils/command/index.ts b/src/utils/command/index.ts index 52e5b1b..1856966 100644 --- a/src/utils/command/index.ts +++ b/src/utils/command/index.ts @@ -1,6 +1,16 @@ -export { parseCommand } from './command-parse'; +export { parseCommand } from './command-parse'; export { parseCommandSchema } from './schema-parse'; export { validateCommand, parseCommandWithSchema, applyCommandSchema } from './command-validate'; +export { + createCommandRegistry, + registerCommand, + unregisterCommand, + hasCommand, + getCommand, + runCommand, + runCommandParsed, + createCommandRunnerContext, +} from './command-registry'; export type { Command, CommandParamSchema, @@ -8,3 +18,5 @@ export type { CommandFlagSchema, CommandSchema, } from './types'; +export type { CommandRunner, CommandRunnerHandler } from './command-runner'; +export type { CommandRegistry, CommandRunnerContext } from './command-registry'; diff --git a/tests/utils/command-runner.test.ts b/tests/utils/command-runner.test.ts new file mode 100644 index 0000000..51fe151 --- /dev/null +++ b/tests/utils/command-runner.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect } from 'vitest'; +import { parseCommandSchema } from '../../src/utils/command/schema-parse'; +import { + createCommandRegistry, + registerCommand, + unregisterCommand, + hasCommand, + getCommand, + runCommand, + createCommandRunnerContext, + type CommandRegistry, + type CommandRunnerContext, +} from '../../src/utils/command/command-registry'; +import type { CommandRunner } from '../../src/utils/command/command-runner'; + +type TestContext = { + counter: number; + log: string[]; +}; + +describe('CommandRegistry', () => { + it('should create an empty registry', () => { + const registry = createCommandRegistry(); + expect(registry.size).toBe(0); + }); + + it('should register a command', () => { + const registry = createCommandRegistry(); + const runner: CommandRunner = { + schema: parseCommandSchema('add '), + run: async function (cmd) { + return Number(cmd.params[0]) + Number(cmd.params[1]); + }, + }; + registerCommand(registry, runner); + expect(registry.size).toBe(1); + expect(hasCommand(registry, 'add')).toBe(true); + }); + + it('should unregister a command', () => { + const registry = createCommandRegistry(); + const runner: CommandRunner = { + schema: parseCommandSchema('remove'), + run: async () => {}, + }; + registerCommand(registry, runner); + expect(hasCommand(registry, 'remove')).toBe(true); + unregisterCommand(registry, 'remove'); + expect(hasCommand(registry, 'remove')).toBe(false); + }); + + it('should get a command runner', () => { + const registry = createCommandRegistry(); + const runner: CommandRunner = { + schema: parseCommandSchema('get'), + run: async () => {}, + }; + registerCommand(registry, runner); + const retrieved = getCommand(registry, 'get'); + expect(retrieved).toBe(runner); + }); + + it('should return undefined for unknown command', () => { + const registry = createCommandRegistry(); + const retrieved = getCommand(registry, 'unknown'); + expect(retrieved).toBeUndefined(); + }); +}); + +describe('runCommand', () => { + it('should run a command successfully', async () => { + const registry = createCommandRegistry(); + const runner: CommandRunner = { + schema: parseCommandSchema('add '), + run: async function (cmd) { + return Number(cmd.params[0]) + Number(cmd.params[1]); + }, + }; + registerCommand(registry, runner); + + const result = await runCommand(registry, { counter: 0, log: [] }, 'add 1 2'); + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe(3); + } + }); + + it('should fail for unknown command', async () => { + const registry = createCommandRegistry(); + const result = await runCommand(registry, { counter: 0, log: [] }, 'unknown'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('Unknown command'); + } + }); + + it('should fail for invalid command params', async () => { + const registry = createCommandRegistry(); + const runner: CommandRunner = { + schema: parseCommandSchema('add '), + run: async () => {}, + }; + registerCommand(registry, runner); + + const result = await runCommand(registry, { counter: 0, log: [] }, 'add 1'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('参数不足'); + } + }); + + it('should access context via this.context', async () => { + const registry = createCommandRegistry(); + const runner: CommandRunner = { + schema: parseCommandSchema('increment'), + run: async function () { + this.context.counter++; + return this.context.counter; + }, + }; + registerCommand(registry, runner); + + const ctx = { counter: 0, log: [] }; + await runCommand(registry, ctx, 'increment'); + expect(ctx.counter).toBe(1); + }); + + it('should handle async errors', async () => { + const registry = createCommandRegistry(); + const runner: CommandRunner = { + schema: parseCommandSchema('fail'), + run: async () => { + throw new Error('Something went wrong'); + }, + }; + registerCommand(registry, runner); + + const result = await runCommand(registry, { counter: 0, log: [] }, 'fail'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBe('Something went wrong'); + } + }); +}); + +describe('CommandRunnerContext', () => { + it('should create a runner context', () => { + const registry = createCommandRegistry(); + const ctx = { counter: 0, log: [] }; + const runnerCtx = createCommandRunnerContext(registry, ctx); + expect(runnerCtx.registry).toBe(registry); + expect(runnerCtx.context).toBe(ctx); + }); + + it('should run commands via runner context', async () => { + const registry = createCommandRegistry(); + const runner: CommandRunner = { + schema: parseCommandSchema('greet '), + run: async function (cmd) { + this.context.log.push(`Hello, ${cmd.params[0]}!`); + return `Hello, ${cmd.params[0]}!`; + }, + }; + registerCommand(registry, runner); + + const ctx = { counter: 0, log: [] }; + const runnerCtx = createCommandRunnerContext(registry, ctx); + const result = await runnerCtx.run('greet World'); + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe('Hello, World!'); + } + expect(ctx.log).toEqual(['Hello, World!']); + }); + + it('should allow commands to call other commands via this.run', async () => { + const registry = createCommandRegistry(); + + const addRunner: CommandRunner = { + schema: parseCommandSchema('add '), + run: async function (cmd) { + return Number(cmd.params[0]) + Number(cmd.params[1]); + }, + }; + + registerCommand(registry, addRunner); + + const multiplyRunner: CommandRunner = { + schema: parseCommandSchema('multiply '), + run: async function (cmd) { + const a = Number(cmd.params[0]); + const b = Number(cmd.params[1]); + const addResult = await this.run(`add ${a} ${a}`); + if (!addResult.success) throw new Error('add failed'); + return (addResult.result as number) * b; + }, + }; + + registerCommand(registry, multiplyRunner); + + const ctx = { counter: 0, log: [] }; + const result = await runCommand(registry, ctx, 'multiply 3 4'); + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe(24); + } + }); + + it('should allow commands to call other commands via this.runParsed', async () => { + const registry = createCommandRegistry(); + + const doubleRunner: CommandRunner = { + schema: parseCommandSchema('double '), + run: async function (cmd) { + return Number(cmd.params[0]) * 2; + }, + }; + + registerCommand(registry, doubleRunner); + + const quadrupleRunner: CommandRunner = { + schema: parseCommandSchema('quadruple '), + run: async function (cmd) { + const n = Number(cmd.params[0]); + const doubleResult = await this.runParsed({ name: 'double', params: [String(n)], options: {}, flags: {} }); + if (!doubleResult.success) throw new Error('double failed'); + return (doubleResult.result as number) * 2; + }, + }; + + registerCommand(registry, quadrupleRunner); + + const ctx = { counter: 0, log: [] }; + const result = await runCommand(registry, ctx, 'quadruple 5'); + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe(20); + } + }); +});