Skip to content

Commit a9ca7f9

Browse files
committed
fix(cli): handle multiple --persist.outputDir arguments
1 parent b3cc57a commit a9ca7f9

File tree

6 files changed

+105
-6
lines changed

6 files changed

+105
-6
lines changed

packages/cli/src/lib/implementation/core-config.int.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ vi.mock('@code-pushup/core', async () => {
1818
return {
1919
...(core as object),
2020
readRcByPath: vi.fn().mockImplementation((filepath: string): CoreConfig => {
21-
const allPersistOptions = {
21+
const allPersistOptions: CoreConfig = {
2222
...CORE_CONFIG_MOCK,
2323
persist: {
2424
filename: 'rc-filename',
2525
format: ['json', 'md'],
2626
outputDir: 'rc-outputDir',
2727
},
2828
};
29-
const persistOnlyFilename = {
29+
const persistOnlyFilename: CoreConfig = {
3030
...CORE_CONFIG_MOCK,
3131
persist: {
3232
filename: 'rc-filename',

packages/cli/src/lib/yargs-cli.int.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@ describe('yargsCli', () => {
103103
expect(parsedArgv.config).toBe('./config.b.ts');
104104
});
105105

106+
it('should use the last occurrence of an argument if persist.outputDir is passed multiple times', async () => {
107+
const parsedArgv = await yargsCli<Pick<CoreConfig, 'persist'>>(
108+
['--persist.outputDir=output-a', '--persist.outputDir=output-b'],
109+
{ options },
110+
).parseAsync();
111+
expect(parsedArgv.persist!.outputDir).toBe('output-b');
112+
});
113+
106114
it('should ignore unknown options', async () => {
107115
const parsedArgv = await yargsCli<GlobalOptions>(
108116
['--no-progress', '--verbose'],

packages/cli/src/lib/yargs-cli.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
formatSchema,
1414
validate,
1515
} from '@code-pushup/models';
16-
import { TERMINAL_WIDTH } from '@code-pushup/utils';
16+
import { TERMINAL_WIDTH, isRecord } from '@code-pushup/utils';
1717
import {
1818
descriptionStyle,
1919
formatNestedValues,
@@ -88,11 +88,11 @@ export function yargsCli<T = unknown>(
8888
.parserConfiguration({
8989
'strip-dashed': true,
9090
} satisfies Partial<ParserConfigurationOptions>)
91-
.coerce('config', (config: string | string[]) =>
92-
Array.isArray(config) ? config.at(-1) : config,
93-
)
9491
.options(formatNestedValues(options, 'describe'));
9592

93+
// use last argument for non-array options
94+
coerceArraysByOptionType(cli, options);
95+
9696
// usage message
9797
if (usageMessage) {
9898
cli.usage(titleStyle(usageMessage));
@@ -166,3 +166,62 @@ function validatePersistFormat(persist: PersistConfig) {
166166
);
167167
}
168168
}
169+
170+
function coerceArraysByOptionType(
171+
cli: Argv,
172+
options: Record<string, Options>,
173+
): void {
174+
Object.entries(groupOptionsByKey(options)).forEach(([key, node]) => {
175+
cli.coerce(key, (value: unknown) => coerceNode(node, value));
176+
});
177+
}
178+
179+
function coerceNode(
180+
node: OptionsTreeNode | OptionsTreeLeaf,
181+
value: unknown,
182+
): unknown {
183+
if (node.isLeaf) {
184+
if (node.options.type === 'array') {
185+
return node.options.coerce?.(value) ?? value;
186+
}
187+
return Array.isArray(value) ? value.at(-1) : value;
188+
}
189+
return Object.entries(node.children).reduce(coerceChildNode, value);
190+
}
191+
192+
function coerceChildNode(
193+
value: unknown,
194+
[key, node]: [string, OptionsTreeNode | OptionsTreeLeaf],
195+
): unknown {
196+
if (!isRecord(value) || !(key in value)) {
197+
return value;
198+
}
199+
return { ...value, [key]: coerceNode(node, value[key]) };
200+
}
201+
202+
type OptionsTree = Record<string, OptionsTreeNode | OptionsTreeLeaf>;
203+
type OptionsTreeNode = { isLeaf: false; children: OptionsTree };
204+
type OptionsTreeLeaf = { isLeaf: true; options: Options };
205+
206+
function groupOptionsByKey(options: Record<string, Options>): OptionsTree {
207+
return Object.entries(options).reduce<OptionsTree>(addOptionToTree, {});
208+
}
209+
210+
function addOptionToTree(
211+
tree: OptionsTree,
212+
[key, value]: [string, Options],
213+
): OptionsTree {
214+
if (!key.includes('.')) {
215+
return { ...tree, [key]: { isLeaf: true, options: value } };
216+
}
217+
const [parentKey, childKey] = key.split('.', 2) as [string, string];
218+
const prevChildren =
219+
tree[parentKey] && !tree[parentKey].isLeaf ? tree[parentKey].children : {};
220+
return {
221+
...tree,
222+
[parentKey]: {
223+
isLeaf: false,
224+
children: addOptionToTree(prevChildren, [childKey, value]),
225+
},
226+
};
227+
}

packages/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export {
8484
hasNoNullableProps,
8585
isPromiseFulfilledResult,
8686
isPromiseRejectedResult,
87+
isRecord,
8788
} from './lib/guards.js';
8889
export { interpolate } from './lib/interpolate.js';
8990
export { logMultipleResults } from './lib/log-results.js';

packages/utils/src/lib/guards.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,7 @@ export function hasNoNullableProps<T extends object>(
1717
): obj is ExcludeNullableProps<T> {
1818
return Object.values(obj).every(value => value != null);
1919
}
20+
21+
export function isRecord(value: unknown): value is Record<string, unknown> {
22+
return typeof value === 'object' && value != null;
23+
}

packages/utils/src/lib/guards.unit.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
hasNoNullableProps,
44
isPromiseFulfilledResult,
55
isPromiseRejectedResult,
6+
isRecord,
67
} from './guards.js';
78

89
describe('promise-result', () => {
@@ -42,3 +43,29 @@ describe('hasNoNullableProps', () => {
4243
expect(hasNoNullableProps({})).toBeTrue();
4344
});
4445
});
46+
47+
describe('isRecord', () => {
48+
it('should return true for an object', () => {
49+
expect(isRecord({ foo: 'bar' })).toBeTrue();
50+
});
51+
52+
it('should return true for an empty object', () => {
53+
expect(isRecord({})).toBeTrue();
54+
});
55+
56+
it('should return true for an array', () => {
57+
expect(isRecord([1, 2, 3])).toBeTrue();
58+
});
59+
60+
it('should return false for a string', () => {
61+
expect(isRecord('foo')).toBeFalse();
62+
});
63+
64+
it('should return false for null', () => {
65+
expect(isRecord(null)).toBeFalse();
66+
});
67+
68+
it('should return false for undefined', () => {
69+
expect(isRecord(undefined)).toBeFalse();
70+
});
71+
});

0 commit comments

Comments
 (0)