From d4746f31272e66fa33493c27799de04444e4a285 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Mon, 16 Dec 2024 09:56:51 -0700 Subject: [PATCH 1/2] feat: add atLeastOne flag property --- src/interfaces/parser.ts | 4 +++ src/parser/validate.ts | 26 ++++++++++++++++--- test/parser/parse.test.ts | 53 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/interfaces/parser.ts b/src/interfaces/parser.ts index 0c6a20f1a..f88a56683 100644 --- a/src/interfaces/parser.ts +++ b/src/interfaces/parser.ts @@ -171,6 +171,10 @@ export type FlagProps = { * This is helpful if the default value contains sensitive data that shouldn't be published to npm. */ noCacheDefault?: boolean + /** + * At least one of these flags must be provided. + */ + atLeastOne?: string[] } export type ArgProps = { diff --git a/src/parser/validate.ts b/src/parser/validate.ts index 9e2b32c21..df6d1fffb 100644 --- a/src/parser/validate.ts +++ b/src/parser/validate.ts @@ -80,7 +80,11 @@ export async function validate(parse: {input: ParserInput; output: ParserOutput} } if (flag.exactlyOne && flag.exactlyOne.length > 0) { - return [validateAcrossFlags(flag)] + return [validateExactlyOneAcrossFlags(flag)] + } + + if (flag.atLeastOne && flag.atLeastOne.length > 0) { + return [validateAtLeastOneAcrossFlags(flag)] } return [] @@ -115,8 +119,8 @@ export async function validate(parse: {input: ParserInput; output: ParserOutput} const getPresentFlags = (flags: Record): string[] => Object.keys(flags).filter((key) => key !== undefined) - function validateAcrossFlags(flag: Flag): Validation { - const base = {name: flag.name, validationFn: 'validateAcrossFlags'} + function validateExactlyOneAcrossFlags(flag: Flag): Validation { + const base = {name: flag.name, validationFn: 'validateExactlyOneAcrossFlags'} const intersection = Object.entries(parse.input.flags) .map((entry) => entry[0]) // array of flag names .filter((flagName) => parse.output.flags[flagName] !== undefined) // with values @@ -131,6 +135,22 @@ export async function validate(parse: {input: ParserInput; output: ParserOutput} return {...base, status: 'success'} } + function validateAtLeastOneAcrossFlags(flag: Flag): Validation { + const base = {name: flag.name, validationFn: 'validateAtLeastOneAcrossFlags'} + const intersection = Object.entries(parse.input.flags) + .map((entry) => entry[0]) // array of flag names + .filter((flagName) => parse.output.flags[flagName] !== undefined) // with values + .filter((flagName) => flag.atLeastOne && flag.atLeastOne.includes(flagName)) // and in the atLeastOne list + if (intersection.length === 0) { + // the command's atLeastOne may or may not include itself, so we'll use Set to add + de-dupe + const deduped = uniq(flag.atLeastOne?.map((flag) => `--${flag}`) ?? []).join(', ') + const reason = `At least one of the following must be provided: ${deduped}` + return {...base, reason, status: 'failed'} + } + + return {...base, status: 'success'} + } + async function validateExclusive(name: string, flags: FlagRelationship[]): Promise { const base = {name, validationFn: 'validateExclusive'} const resolved = await resolveFlags(flags) diff --git a/test/parser/parse.test.ts b/test/parser/parse.test.ts index 1a5cbd48f..a6ed592e3 100644 --- a/test/parser/parse.test.ts +++ b/test/parser/parse.test.ts @@ -1644,6 +1644,59 @@ See more help with --help`) }) }) + describe('atLeastOne', () => { + it('throws if none are set', async () => { + let message = '' + try { + await parse([], { + flags: { + foo: Flags.string({atLeastOne: ['foo', 'bar']}), + bar: Flags.string({char: 'b', atLeastOne: ['foo', 'bar']}), + }, + }) + } catch (error: any) { + message = error.message + } + + expect(message).to.include('At least one of the following must be provided: --bar, --foo') + }) + + it('succeeds if one is set', async () => { + const out = await parse(['--foo', 'a'], { + flags: { + foo: Flags.string({atLeastOne: ['foo', 'bar', 'baz']}), + bar: Flags.string({char: 'b', atLeastOne: ['foo', 'bar', 'baz']}), + baz: Flags.string({char: 'z'}), + }, + }) + expect(out.flags.foo).to.equal('a') + }) + + it('succeeds if some are set', async () => { + const out = await parse(['--bar', 'b'], { + flags: { + foo: Flags.string({atLeastOne: ['foo', 'bar', 'baz']}), + bar: Flags.string({char: 'b', atLeastOne: ['foo', 'bar', 'baz']}), + baz: Flags.string({char: 'z'}), + }, + }) + expect(out.flags.bar).to.equal('b') + }) + + it('succeeds if all are set', async () => { + const out = await parse(['--foo', 'a', '--bar', 'b', '--baz', 'c'], { + flags: { + foo: Flags.string({atLeastOne: ['foo', 'bar', 'baz']}), + bar: Flags.string({char: 'b', atLeastOne: ['foo', 'bar', 'baz']}), + baz: Flags.string({char: 'z'}), + }, + }) + expect(out.flags.foo).to.equal('a') + expect(out.flags.bar).to.equal('b') + expect(out.flags.baz).to.equal('c') + }) + }) + describe('allowNo', () => { it('is undefined if not set', async () => { const out = await parse([], { From bebaa1cecae240e0a1f529855f5787b40c90f967 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Tue, 17 Dec 2024 09:37:14 -0700 Subject: [PATCH 2/2] chore: apply suggestions from code review Co-authored-by: Willhoit --- test/parser/parse.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/parser/parse.test.ts b/test/parser/parse.test.ts index a6ed592e3..bb51f1b71 100644 --- a/test/parser/parse.test.ts +++ b/test/parser/parse.test.ts @@ -1673,13 +1673,14 @@ See more help with --help`) }) it('succeeds if some are set', async () => { - const out = await parse(['--bar', 'b'], { + const out = await parse(['--foo', 'a', '--bar', 'b'], { flags: { foo: Flags.string({atLeastOne: ['foo', 'bar', 'baz']}), bar: Flags.string({char: 'b', atLeastOne: ['foo', 'bar', 'baz']}), baz: Flags.string({char: 'z'}), }, }) + expect(out.flags.foo).to.equal('a') expect(out.flags.bar).to.equal('b') })