/** * OpenFang Configuration Parser * * Provides configuration parsing, validation, and serialization for OpenFang TOML files. * * @module lib/config-parser */ import { tomlUtils, TomlParseError } from './toml-utils'; import type { OpenFangConfig, ConfigValidationResult, ConfigValidationError, ConfigValidationWarning, ConfigFileMetadata, ServerConfig, AgentSectionConfig, LLMConfig, } from '../types/config'; /** * Error class for configuration parsing errors */ export class ConfigParseError extends Error { constructor( message: string, public readonly cause?: unknown ) { super(message); this.name = 'ConfigParseError'; } } /** * Error class for configuration validation errors (thrown when validation fails) */ export class ConfigValidationFailedError extends Error { constructor( message: string, public readonly errors: ConfigValidationError[] ) { super(message); this.name = 'ConfigValidationFailedError'; } } /** * Required configuration fields with their paths */ const REQUIRED_FIELDS: Array<{ path: string; description: string }> = [ { path: 'server', description: 'Server configuration' }, { path: 'server.host', description: 'Server host address' }, { path: 'server.port', description: 'Server port number' }, { path: 'agent', description: 'Agent configuration' }, { path: 'agent.defaults', description: 'Agent defaults' }, { path: 'agent.defaults.workspace', description: 'Default workspace path' }, { path: 'agent.defaults.default_model', description: 'Default model name' }, { path: 'llm', description: 'LLM configuration' }, { path: 'llm.default_provider', description: 'Default LLM provider' }, { path: 'llm.default_model', description: 'Default LLM model' }, ]; /** * Default configuration values */ const DEFAULT_CONFIG: Partial = { server: { host: '127.0.0.1', port: 50051, websocket_port: 50051, websocket_path: '/ws', api_version: 'v1', }, agent: { defaults: { workspace: '~/.openfang/workspace', default_model: 'gpt-4', }, }, llm: { default_provider: 'openai', default_model: 'gpt-4', }, }; /** * Configuration parser and validator */ export const configParser = { /** * Parse TOML content into an OpenFang configuration object * * @param content - The TOML content to parse * @param envVars - Optional environment variables for resolution * @returns The parsed configuration object * @throws ConfigParseError if parsing fails * * @example * ```typescript * const config = configParser.parseConfig(tomlContent, { OPENAI_API_KEY: 'sk-...' }); * ``` */ parseConfig: (content: string, envVars?: Record): OpenFangConfig => { try { // First resolve environment variables const resolved = tomlUtils.resolveEnvVars(content, envVars); // Parse TOML const parsed = tomlUtils.parse(resolved); return parsed; } catch (error) { if (error instanceof TomlParseError) { throw new ConfigParseError(`Failed to parse configuration: ${error.message}`, error); } throw new ConfigParseError( `Unexpected error parsing configuration: ${error instanceof Error ? error.message : String(error)}`, error ); } }, /** * Validate an OpenFang configuration object * * @param config - The configuration object to validate * @returns Validation result with errors and warnings * * @example * ```typescript * const result = configParser.validateConfig(parsedConfig); * if (!result.valid) { * console.error('Config errors:', result.errors); * } * ``` */ validateConfig: (config: unknown): ConfigValidationResult => { const errors: ConfigValidationError[] = []; const warnings: ConfigValidationWarning[] = []; // Basic type check if (typeof config !== 'object' || config === null) { errors.push({ path: '', message: 'Configuration must be a non-null object', severity: 'error', }); return { valid: false, errors, warnings }; } const cfg = config as Record; // Check required fields for (const { path, description } of REQUIRED_FIELDS) { const value = getNestedValue(cfg, path); if (value === undefined) { errors.push({ path, message: `Missing required field: ${description}`, severity: 'error', }); } } // Validate server configuration if (cfg.server && typeof cfg.server === 'object') { const server = cfg.server as ServerConfig; if (typeof server.port === 'number' && (server.port < 1 || server.port > 65535)) { errors.push({ path: 'server.port', message: 'Port must be between 1 and 65535', severity: 'error', }); } if (typeof server.host === 'string' && server.host.length === 0) { errors.push({ path: 'server.host', message: 'Host cannot be empty', severity: 'error', }); } } // Validate agent configuration if (cfg.agent && typeof cfg.agent === 'object') { const agent = cfg.agent as AgentSectionConfig; if (agent.defaults) { if (typeof agent.defaults.workspace === 'string' && agent.defaults.workspace.length === 0) { warnings.push({ path: 'agent.defaults.workspace', message: 'Workspace path is empty', severity: 'warning', }); } if (typeof agent.defaults.default_model === 'string' && agent.defaults.default_model.length === 0) { errors.push({ path: 'agent.defaults.default_model', message: 'Default model cannot be empty', severity: 'error', }); } } } // Validate LLM configuration if (cfg.llm && typeof cfg.llm === 'object') { const llm = cfg.llm as LLMConfig; if (typeof llm.default_provider === 'string' && llm.default_provider.length === 0) { errors.push({ path: 'llm.default_provider', message: 'Default provider cannot be empty', severity: 'error', }); } if (typeof llm.default_model === 'string' && llm.default_model.length === 0) { errors.push({ path: 'llm.default_model', message: 'Default model cannot be empty', severity: 'error', }); } } return { valid: errors.length === 0, errors, warnings, }; }, /** * Parse and validate configuration in one step * * @param content - The TOML content to parse * @param envVars - Optional environment variables for resolution * @returns The parsed and validated configuration * @throws ConfigParseError if parsing fails * @throws ConfigValidationFailedError if validation fails * * @example * ```typescript * const config = configParser.parseAndValidate(tomlContent); * ``` */ parseAndValidate: ( content: string, envVars?: Record ): OpenFangConfig => { const config = configParser.parseConfig(content, envVars); const result = configParser.validateConfig(config); if (!result.valid) { throw new ConfigValidationFailedError( `Configuration validation failed: ${result.errors.map((e) => e.message).join(', ')}`, result.errors ); } return config; }, /** * Serialize a configuration object to TOML format * * @param config - The configuration object to serialize * @returns The TOML string * * @example * ```typescript * const toml = configParser.stringifyConfig(config); * ``` */ stringifyConfig: (config: OpenFangConfig): string => { return tomlUtils.stringify(config as unknown as Record); }, /** * Merge partial configuration with defaults * * @param config - Partial configuration to merge * @returns Complete configuration with defaults applied * * @example * ```typescript * const fullConfig = configParser.mergeWithDefaults(partialConfig); * ``` */ mergeWithDefaults: (config: Partial): OpenFangConfig => { return deepMerge(DEFAULT_CONFIG, config) as unknown as OpenFangConfig; }, /** * Extract metadata from a TOML configuration file * * @param content - The TOML content * @param filePath - The file path * @returns Configuration file metadata * * @example * ```typescript * const metadata = configParser.extractMetadata(tomlContent, '/path/to/config.toml'); * console.log('Env vars needed:', metadata.envVars); * ``` */ extractMetadata: (content: string, filePath: string): ConfigFileMetadata => { const envVars = tomlUtils.extractEnvVarNames(content); const hasUnresolvedEnvVars = tomlUtils.hasUnresolvedEnvVars(content); return { path: filePath, name: filePath.split('/').pop() || filePath, envVars, hasUnresolvedEnvVars, }; }, /** * Get default configuration * * @returns Default OpenFang configuration */ getDefaults: (): OpenFangConfig => { return JSON.parse(JSON.stringify(DEFAULT_CONFIG)) as OpenFangConfig; }, /** * Check if a configuration object is valid * * @param config - The configuration to check * @returns Type guard for OpenFangConfig */ isOpenFangConfig: (config: unknown): config is OpenFangConfig => { const result = configParser.validateConfig(config); return result.valid; }, }; /** * Helper function to get a nested value from an object using dot-notation path */ function getNestedValue(obj: Record, path: string): unknown { const parts = path.split('.'); let current: unknown = obj; for (const part of parts) { if (current === null || current === undefined) { return undefined; } if (typeof current !== 'object') { return undefined; } current = (current as Record)[part]; } return current; } /** * Helper function to deep merge two objects */ function deepMerge>( target: Partial, source: Partial ): Partial { const result = { ...target }; for (const key of Object.keys(source) as (keyof T)[]) { const sourceValue = source[key]; const targetValue = target[key]; if ( sourceValue !== undefined && typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue) && targetValue !== undefined && typeof targetValue === 'object' && targetValue !== null && !Array.isArray(targetValue) ) { result[key] = deepMerge( targetValue as Record, sourceValue as Record ) as T[keyof T]; } else if (sourceValue !== undefined) { // Safe assignment: sourceValue is typed as Partial[keyof T], result[key] expects T[keyof T] result[key] = sourceValue as T[keyof T]; } } return result; } export default configParser;