Skip to content

Commit 2a4d44c

Browse files
committed
feat(config): implement config file loader, ignore parser, and context loader
- Load codingbuddy.config.{js,mjs,json} with validation - Parse .codingignore files with gitignore-style patterns - Load .codingbuddy/ directory files (context, prompts, agents) - Add NestJS ConfigService integrating all loaders - Add comprehensive test coverage close #20
1 parent 5c876ac commit 2a4d44c

9 files changed

Lines changed: 1307 additions & 1 deletion
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
CONFIG_FILE_NAMES,
4+
ConfigLoadError,
5+
validateAndTransform,
6+
} from './config.loader';
7+
8+
describe('config.loader', () => {
9+
describe('CONFIG_FILE_NAMES', () => {
10+
it('should have correct priority order', () => {
11+
expect(CONFIG_FILE_NAMES[0]).toBe('codingbuddy.config.js');
12+
expect(CONFIG_FILE_NAMES[1]).toBe('codingbuddy.config.mjs');
13+
expect(CONFIG_FILE_NAMES[2]).toBe('codingbuddy.config.json');
14+
});
15+
16+
it('should have 3 supported file names', () => {
17+
expect(CONFIG_FILE_NAMES).toHaveLength(3);
18+
});
19+
});
20+
21+
describe('ConfigLoadError', () => {
22+
it('should create error with message and file path', () => {
23+
const error = new ConfigLoadError('Test error', '/path/to/config.js');
24+
25+
expect(error.message).toBe('Test error');
26+
expect(error.filePath).toBe('/path/to/config.js');
27+
expect(error.name).toBe('ConfigLoadError');
28+
});
29+
30+
it('should include cause when provided', () => {
31+
const cause = new Error('Original error');
32+
const error = new ConfigLoadError('Wrapped error', '/path/to/config.js', cause);
33+
34+
expect(error.cause).toBe(cause);
35+
});
36+
37+
it('should be instanceof Error', () => {
38+
const error = new ConfigLoadError('Test', '/path');
39+
expect(error).toBeInstanceOf(Error);
40+
});
41+
});
42+
43+
describe('validateAndTransform', () => {
44+
it('should accept valid config', () => {
45+
const raw = {
46+
language: 'ko',
47+
projectName: 'test-project',
48+
techStack: {
49+
frontend: ['React'],
50+
},
51+
};
52+
53+
const result = validateAndTransform(raw, '/path/config.json');
54+
55+
expect(result.config.language).toBe('ko');
56+
expect(result.config.projectName).toBe('test-project');
57+
expect(result.config.techStack?.frontend).toEqual(['React']);
58+
expect(result.warnings).toEqual([]);
59+
});
60+
61+
it('should accept empty config', () => {
62+
const result = validateAndTransform({}, '/path/config.json');
63+
64+
expect(result.config).toEqual({});
65+
expect(result.warnings).toEqual([]);
66+
});
67+
68+
it('should throw ConfigLoadError for invalid config', () => {
69+
const raw = {
70+
testStrategy: {
71+
coverage: 200, // invalid: max 100
72+
},
73+
};
74+
75+
expect(() => validateAndTransform(raw, '/path/config.json')).toThrow(ConfigLoadError);
76+
});
77+
78+
it('should include field path in error message', () => {
79+
const raw = {
80+
conventions: {
81+
naming: {
82+
files: 'invalid-value',
83+
},
84+
},
85+
};
86+
87+
try {
88+
validateAndTransform(raw, '/path/config.json');
89+
expect.fail('Should have thrown');
90+
} catch (error) {
91+
expect(error).toBeInstanceOf(ConfigLoadError);
92+
expect((error as ConfigLoadError).message).toContain('conventions');
93+
}
94+
});
95+
96+
it('should throw ConfigLoadError for invalid URL in repository', () => {
97+
const raw = {
98+
repository: 'not-a-valid-url',
99+
};
100+
101+
expect(() => validateAndTransform(raw, '/path/config.json')).toThrow(ConfigLoadError);
102+
});
103+
});
104+
});
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import * as fs from 'fs/promises';
2+
import { existsSync } from 'fs';
3+
import * as path from 'path';
4+
import { pathToFileURL } from 'url';
5+
import { validateConfig, type CodingBuddyConfig } from './config.schema';
6+
7+
/**
8+
* Supported config file names in priority order
9+
*/
10+
export const CONFIG_FILE_NAMES = [
11+
'codingbuddy.config.js',
12+
'codingbuddy.config.mjs',
13+
'codingbuddy.config.json',
14+
] as const;
15+
16+
/**
17+
* Result of loading a config file
18+
*/
19+
export interface ConfigLoadResult {
20+
/** Loaded and validated configuration */
21+
config: CodingBuddyConfig;
22+
/** Path to the loaded config file (null if no file found) */
23+
source: string | null;
24+
/** Warning messages (e.g., validation issues that were auto-fixed) */
25+
warnings: string[];
26+
}
27+
28+
/**
29+
* Error thrown when config loading fails
30+
*/
31+
export class ConfigLoadError extends Error {
32+
constructor(
33+
message: string,
34+
public readonly filePath: string,
35+
public readonly cause?: Error,
36+
) {
37+
super(message);
38+
this.name = 'ConfigLoadError';
39+
}
40+
}
41+
42+
/**
43+
* Find the config file in the project root
44+
*/
45+
export function findConfigFile(projectRoot: string): string | null {
46+
for (const fileName of CONFIG_FILE_NAMES) {
47+
const filePath = path.join(projectRoot, fileName);
48+
if (existsSync(filePath)) {
49+
return filePath;
50+
}
51+
}
52+
return null;
53+
}
54+
55+
/**
56+
* Load a JavaScript/ESM config file using dynamic import
57+
*/
58+
export async function loadJsConfig(filePath: string): Promise<unknown> {
59+
try {
60+
// Convert to file:// URL for cross-platform compatibility (Windows/Unix)
61+
const fileUrl = pathToFileURL(filePath).href;
62+
const module = await import(fileUrl);
63+
64+
// Handle both default export and module.exports
65+
return module.default ?? module;
66+
} catch (error) {
67+
throw new ConfigLoadError(
68+
`Failed to load JavaScript config: ${error instanceof Error ? error.message : String(error)}`,
69+
filePath,
70+
error instanceof Error ? error : undefined,
71+
);
72+
}
73+
}
74+
75+
/**
76+
* Load a JSON config file
77+
*/
78+
export async function loadJsonConfig(filePath: string): Promise<unknown> {
79+
try {
80+
const content = await fs.readFile(filePath, 'utf-8');
81+
return JSON.parse(content);
82+
} catch (error) {
83+
if (error instanceof SyntaxError) {
84+
throw new ConfigLoadError(
85+
`Invalid JSON in config file: ${error.message}`,
86+
filePath,
87+
error,
88+
);
89+
}
90+
throw new ConfigLoadError(
91+
`Failed to read config file: ${error instanceof Error ? error.message : String(error)}`,
92+
filePath,
93+
error instanceof Error ? error : undefined,
94+
);
95+
}
96+
}
97+
98+
/**
99+
* Load config from a file path (auto-detects format)
100+
*/
101+
export async function loadConfigFromFile(filePath: string): Promise<unknown> {
102+
const ext = path.extname(filePath).toLowerCase();
103+
104+
if (ext === '.json') {
105+
return loadJsonConfig(filePath);
106+
}
107+
108+
if (ext === '.js' || ext === '.mjs') {
109+
return loadJsConfig(filePath);
110+
}
111+
112+
throw new ConfigLoadError(
113+
`Unsupported config file format: ${ext}`,
114+
filePath,
115+
);
116+
}
117+
118+
/**
119+
* Validate and transform raw config into CodingBuddyConfig
120+
*/
121+
export function validateAndTransform(
122+
raw: unknown,
123+
filePath: string,
124+
): { config: CodingBuddyConfig; warnings: string[] } {
125+
const result = validateConfig(raw);
126+
127+
if (!result.success) {
128+
const errorMessages = result.errors!
129+
.map((e) => ` - ${e.path}: ${e.message}`)
130+
.join('\n');
131+
132+
throw new ConfigLoadError(
133+
`Invalid configuration:\n${errorMessages}`,
134+
filePath,
135+
);
136+
}
137+
138+
return {
139+
config: result.data!,
140+
warnings: [],
141+
};
142+
}
143+
144+
/**
145+
* Load project configuration from the specified root directory
146+
*
147+
* @param projectRoot - Project root directory (defaults to process.cwd())
148+
* @returns Loaded configuration with metadata
149+
*/
150+
export async function loadConfig(projectRoot?: string): Promise<ConfigLoadResult> {
151+
const root = projectRoot ?? process.cwd();
152+
const configPath = findConfigFile(root);
153+
154+
// No config file found - return empty config
155+
if (!configPath) {
156+
return {
157+
config: {},
158+
source: null,
159+
warnings: [],
160+
};
161+
}
162+
163+
// Load and validate config
164+
const raw = await loadConfigFromFile(configPath);
165+
const { config, warnings } = validateAndTransform(raw, configPath);
166+
167+
return {
168+
config,
169+
source: configPath,
170+
warnings,
171+
};
172+
}
173+
174+
/**
175+
* Check if a config file exists in the project root
176+
*/
177+
export function hasConfigFile(projectRoot: string): boolean {
178+
return findConfigFile(projectRoot) !== null;
179+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Module } from '@nestjs/common';
2+
import { ConfigService } from './config.service';
3+
4+
@Module({
5+
providers: [ConfigService],
6+
exports: [ConfigService],
7+
})
8+
export class CodingBuddyConfigModule {}

0 commit comments

Comments
 (0)