Skip to content

Commit 09bb5ae

Browse files
authored
feat: add --output-file CLI option (#66)
1 parent 34961c9 commit 09bb5ae

File tree

9 files changed

+493
-7
lines changed

9 files changed

+493
-7
lines changed

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,44 @@ modestbench --tags fast --exclude-tags experimental
243243

244244
See [Tagging and Filtering](#tagging-and-filtering) for detailed examples.
245245

246+
#### Output Options
247+
248+
Control where and how benchmark results are saved:
249+
250+
```bash
251+
# Write to a directory (creates results.json, results.csv, etc.)
252+
modestbench --reporters json,csv --output ./results
253+
254+
# Custom filename for single reporter
255+
modestbench --reporters json --output-file my-benchmarks.json
256+
257+
# Custom filename in specific directory
258+
modestbench --reporters json --output ./results --output-file benchmarks-2024.json
259+
260+
# Custom filename with absolute path
261+
modestbench --reporters json --output-file /tmp/my-benchmarks.json
262+
263+
# With subdirectories
264+
modestbench --reporters csv --output ./results --output-file reports/performance.csv
265+
266+
# Short flag alias
267+
modestbench --reporters json --of custom.json
268+
```
269+
270+
**Key Options:**
271+
272+
- `--output <dir>`, `-o <dir>` - Directory to write output files (default: stdout)
273+
- `--output-file <filename>`, `--of <filename>` - Custom filename for output
274+
- Works with absolute or relative paths
275+
- Requires exactly one reporter (e.g., `--reporters json`)
276+
- When used with `--output`, the filename is relative to that directory
277+
- When used alone, the path is relative to current working directory
278+
279+
**Limitations:**
280+
281+
- `--output-file` only works with a single reporter
282+
- For multiple reporters, use `--output <dir>` (defaults to `results.json`, `results.csv`, etc.)
283+
246284
### History Management
247285

248286
**modestbench** automatically tracks benchmark results over time in a local `.modestbench/` directory. This history enables you to:

src/cli/commands/run.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { BenchmarkRun } from '../../types/index.js';
1111
import type { CliContext } from '../index.js';
1212

1313
import { ErrorCodes } from '../../constants.js';
14+
import { resolveOutputPath } from '../../core/output-path-resolver.js';
1415
import {
1516
InvalidArgumentError,
1617
type ModestBenchError,
@@ -32,6 +33,7 @@ interface RunOptions {
3233
json?: boolean | undefined;
3334
noColor?: boolean | undefined;
3435
outputDir?: string | undefined;
36+
outputFile?: string | undefined;
3537
pattern: string[];
3638
progress?: boolean | undefined;
3739
quiet?: boolean | undefined;
@@ -61,6 +63,18 @@ export const handleRunCommand = async (
6163
const showCliMessages = verbose && !options.quiet;
6264

6365
try {
66+
// Validate --output-file usage
67+
if (
68+
options.outputFile &&
69+
options.reporters &&
70+
options.reporters.length > 1
71+
) {
72+
throw new InvalidArgumentError(
73+
'--output-file can only be used with a single reporter. ' +
74+
'Use --output <dir> for multiple reporters.',
75+
);
76+
}
77+
6478
// Step 1: Load and merge configuration
6579
if (showCliMessages) {
6680
console.error('Loading configuration...');
@@ -79,6 +93,7 @@ export const handleRunCommand = async (
7993
showCliMessages,
8094
options.quiet ?? false,
8195
options.outputDir,
96+
options.outputFile,
8297
options.progress,
8398
);
8499

@@ -161,10 +176,13 @@ export const handleRunCommand = async (
161176

162177
return handleResults(executionResult, options, shouldBeQuiet);
163178
} catch (error) {
164-
// Re-throw FileDiscoveryError so yargs fail handler can show help
179+
// Re-throw CLI errors so yargs fail handler can show help
165180
if ((error as ModestBenchError).code === ErrorCodes.FILE_DISCOVERY_FAILED) {
166181
throw error;
167182
}
183+
if ((error as ModestBenchError).code === ErrorCodes.CLI_INVALID_ARGUMENT) {
184+
throw error;
185+
}
168186

169187
if (!shouldBeQuiet) {
170188
console.error(
@@ -298,6 +316,7 @@ const setupReporters = async (
298316
showCliMessages: boolean,
299317
explicitQuiet: boolean,
300318
explicitOutputDir?: string,
319+
explicitOutputFile?: string,
301320
progressOption?: boolean,
302321
) => {
303322
try {
@@ -328,17 +347,27 @@ const setupReporters = async (
328347
verbose: isVerbose,
329348
});
330349
} else if (reporterName === 'json') {
350+
const outputPath = resolveOutputPath(
351+
outputDir,
352+
explicitOutputFile,
353+
'results.json',
354+
);
331355
reporter = new JsonReporter({
332-
...(outputDir ? { outputPath: `${outputDir}/results.json` } : {}),
356+
...(outputPath ? { outputPath } : {}),
333357
prettyPrint: true,
334358
quiet: shouldBeQuiet, // JSON uses shouldBeQuiet to avoid polluting stdout
335359
verbose: isVerbose,
336360
});
337361
} else if (reporterName === 'csv') {
362+
const outputPath = resolveOutputPath(
363+
outputDir,
364+
explicitOutputFile,
365+
'results.csv',
366+
);
338367
reporter = new CsvReporter({
339368
includeHeaders: true,
340369
includeMetadata: true,
341-
...(outputDir ? { outputPath: `${outputDir}/results.csv` } : {}),
370+
...(outputPath ? { outputPath } : {}),
342371
quiet: explicitQuiet, // Only applies explicit --quiet flag; CSV output can coexist with progress messages on different streams
343372
verbose: isVerbose,
344373
});

src/cli/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,13 @@ export const main = async (
196196
description: 'Output directory for reports',
197197
type: 'string',
198198
})
199+
.option('output-file', {
200+
alias: 'of',
201+
description:
202+
'Custom filename for reporter output (use with single reporter only)',
203+
requiresArg: true,
204+
type: 'string',
205+
})
199206
.option('iterations', {
200207
alias: 'i',
201208
description: 'Number of iterations per benchmark',
@@ -310,6 +317,7 @@ export const main = async (
310317
json: argv.json,
311318
noColor: argv.noColor,
312319
outputDir: argv.output,
320+
outputFile: argv['output-file'],
313321
pattern: argv.pattern,
314322
progress: argv.progress,
315323
quiet: argv.quiet,

src/core/output-path-resolver.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { isAbsolute, join, resolve } from 'node:path';
2+
3+
/**
4+
* Resolves the final output path for a reporter
5+
*
6+
* @param outputDir - Optional output directory from --output flag
7+
* @param outputFile - Optional output filename from --output-file flag
8+
* @param defaultFilename - Default filename to use if none specified
9+
* @returns Resolved output path, or undefined if no output to file requested
10+
*/
11+
export const resolveOutputPath = (
12+
outputDir?: string,
13+
outputFile?: string,
14+
defaultFilename?: string,
15+
): string | undefined => {
16+
// If outputFile is provided
17+
if (outputFile) {
18+
// If outputFile is absolute, use as-is
19+
if (isAbsolute(outputFile)) {
20+
return outputFile;
21+
}
22+
23+
// If outputDir specified, join them
24+
if (outputDir) {
25+
return join(outputDir, outputFile);
26+
}
27+
28+
// Otherwise, resolve relative to cwd
29+
return resolve(process.cwd(), outputFile);
30+
}
31+
32+
// Fall back to default behavior
33+
if (outputDir && defaultFilename) {
34+
return join(outputDir, defaultFilename);
35+
}
36+
37+
return undefined;
38+
};

src/types/cli.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,11 @@ export interface RunCommandArgs extends CommandArguments {
270270
/** How to limit benchmark execution */
271271
readonly limitBy?: 'all' | 'any' | 'iterations' | 'time';
272272
readonly o?: string;
273+
readonly of?: string;
273274
/** Output directory */
274275
readonly output?: string;
276+
/** Custom output filename (works with single reporter only) */
277+
readonly outputFile?: string;
275278
/** Pattern for discovering benchmark files */
276279
readonly pattern?: string;
277280
readonly q?: boolean;

test/integration/configuration.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,8 +340,11 @@ export default {
340340
);
341341

342342
// Should report validation errors
343+
expect(result.exitCode, 'to be greater than', 0);
343344
expect(
344-
result.exitCode === 2 || result.stderr.includes('not found'),
345+
result.stderr.includes('Configuration validation failed') ||
346+
result.stderr.includes('Too small') ||
347+
result.stderr.includes('Invalid input'),
345348
'to be truthy',
346349
);
347350
});

0 commit comments

Comments
 (0)