diff --git a/src/config/config.ts b/src/config/config.ts index b886da2f..3d032b87 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -768,6 +768,7 @@ export async function toCached(c: Command.Class, plugin?: IPlugin): Promise = { export type Default> = T | ((context: DefaultContext) => Promise) export type DefaultHelp> = T | ((context: DefaultContext) => Promise) +export type FlagRelationship = string | {name: string; when: (flags: Record) => Promise}; +export type Relationship = { + type: 'all' | 'some' | 'none'; + flags: FlagRelationship[]; +} + export type FlagProps = { name: string; char?: AlphabetLowercase | AlphabetUppercase; @@ -133,6 +139,10 @@ export type FlagProps = { * Exactly one of these flags must be provided. */ exactlyOne?: string[]; + /** + * Define complex relationships between flags. + */ + relationships?: Relationship[]; } export type BooleanFlagProps = FlagProps & { diff --git a/src/parser/errors.ts b/src/parser/errors.ts index 22c97c19..b0c952f3 100644 --- a/src/parser/errors.ts +++ b/src/parser/errors.ts @@ -3,7 +3,9 @@ import {CLIError} from '../errors' import Deps from './deps' import * as Help from './help' import * as List from './list' +import * as chalk from 'chalk' import {ParserArg, CLIParseErrorOptions, OptionFlag, Flag} from '../interfaces' +import {uniq} from '../config/util' export {CLIError} from '../errors' @@ -13,6 +15,14 @@ const m = Deps() .add('help', () => require('./help') as typeof Help) // eslint-disable-next-line node/no-missing-require .add('list', () => require('./list') as typeof List) +.add('chalk', () => require('chalk') as typeof chalk) + +export type Validation = { + name: string; + status: 'success' | 'failed'; + validationFn: string; + reason?: string; +} export class CLIParseError extends CLIError { public parse: CLIParseErrorOptions['parse'] @@ -90,3 +100,13 @@ export class ArgInvalidOptionError extends CLIParseError { super({parse: {}, message}) } } + +export class FailedFlagValidationError extends CLIParseError { + constructor({parse, failed}: CLIParseErrorOptions & { failed: Validation[] }) { + const reasons = failed.map(r => r.reason) + const deduped = uniq(reasons) + const errString = deduped.length === 1 ? 'error' : 'errors' + const message = `The following ${errString} occurred:\n ${m.chalk.dim(deduped.join('\n '))}` + super({parse, message}) + } +} diff --git a/src/parser/index.ts b/src/parser/index.ts index 0b322740..aab21c72 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -27,7 +27,7 @@ export async function parse } diff --git a/src/parser/validate.ts b/src/parser/validate.ts index fb5f01c3..be3ec157 100644 --- a/src/parser/validate.ts +++ b/src/parser/validate.ts @@ -1,14 +1,15 @@ -import {CLIError} from '../errors' - import { InvalidArgsSpecError, RequiredArgsError, - RequiredFlagError, + Validation, UnexpectedArgsError, + FailedFlagValidationError, } from './errors' -import {ParserArg, ParserInput, ParserOutput, Flag} from '../interfaces' +import {ParserArg, ParserInput, ParserOutput, Flag, CompletableFlag} from '../interfaces' +import {FlagRelationship} from '../interfaces/parser' +import {uniq} from '../config/util' -export function validate(parse: { +export async function validate(parse: { input: ParserInput; output: ParserOutput; }) { @@ -41,64 +42,147 @@ export function validate(parse: { } } - function validateAcrossFlags(flag: Flag) { + async function validateFlags() { + const promises = Object.entries(parse.input.flags).map(async ([name, flag]) => { + const results: Validation[] = [] + if (parse.output.flags[name] !== undefined) { + results.push( + ...await validateRelationships(name, flag), + await validateDependsOn(name, flag.dependsOn ?? []), + await validateExclusive(name, flag.exclusive ?? []), + await validateExactlyOne(name, flag.exactlyOne ?? []), + ) + } else if (flag.required) { + results.push({status: 'failed', name, validationFn: 'required', reason: `Missing required flag ${name}`}) + } else if (flag.exactlyOne && flag.exactlyOne.length > 0) { + results.push(validateAcrossFlags(flag)) + } + + return results + }) + + const results = (await Promise.all(promises)).flat() + + const failed = results.filter(r => r.status === 'failed') + if (failed.length > 0) throw new FailedFlagValidationError({parse, failed}) + } + + async function resolveFlags(flags: FlagRelationship[]): Promise> { + const promises = flags.map(async flag => { + if (typeof flag === 'string') { + return [flag, parse.output.flags[flag]] + } + + const result = await flag.when(parse.output.flags) + return result ? [flag.name, parse.output.flags[flag.name]] : null + }) + const resolved = await Promise.all(promises) + return Object.fromEntries(resolved.filter(r => r !== null) as [string, unknown][]) + } + + function getPresentFlags(flags: Record): string[] { + return Object.keys(flags).reduce((acc, key) => { + if (flags[key]) acc.push(key) + return acc + }, [] as string[]) + } + + function validateAcrossFlags(flag: Flag): Validation { + const base = {name: flag.name, validationFn: 'validateAcrossFlags'} 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.exactlyOne && flag.exactlyOne.includes(flagName)) // and in the exactlyOne list if (intersection.length === 0) { // the command's exactlyOne may or may not include itself, so we'll use Set to add + de-dupe - throw new CLIError(`Exactly one of the following must be provided: ${[ - ...new Set(flag.exactlyOne?.map(flag => `--${flag}`)), - ].join(', ')}`) + const deduped = uniq(flag.exactlyOne?.map(flag => `--${flag}`) ?? []).join(', ') + const reason = `Exactly one of the following must be provided: ${deduped}` + return {...base, status: 'failed', reason} } + + return {...base, status: 'success'} } - function validateFlags() { - for (const [name, flag] of Object.entries(parse.input.flags)) { - if (parse.output.flags[name] !== undefined) { - for (const also of flag.dependsOn || []) { - if (!parse.output.flags[also]) { - throw new CLIError( - `--${also}= must also be provided when using --${name}=`, - ) - } - } - - for (const also of flag.exclusive || []) { - // do not enforce exclusivity for flags that were defaulted - if ( - parse.output.metadata.flags[also] && - parse.output.metadata.flags[also].setFromDefault - ) - continue - if ( - parse.output.metadata.flags[name] && - parse.output.metadata.flags[name].setFromDefault - ) - continue - if (parse.output.flags[also]) { - throw new CLIError( - `--${also}= cannot also be provided when using --${name}=`, - ) - } - } - - for (const also of flag.exactlyOne || []) { - if (also !== name && parse.output.flags[also]) { - throw new CLIError( - `--${also}= cannot also be provided when using --${name}=`, - ) - } - } - } else if (flag.required) { - throw new RequiredFlagError({parse, flag}) - } else if (flag.exactlyOne && flag.exactlyOne.length > 0) { - validateAcrossFlags(flag) + async function validateExclusive(name: string, flags: FlagRelationship[]): Promise { + const base = {name, validationFn: 'validateExclusive'} + const resolved = await resolveFlags(flags) + const keys = getPresentFlags(resolved) + for (const flag of keys) { + // do not enforce exclusivity for flags that were defaulted + if (parse.output.metadata.flags && parse.output.metadata.flags[flag]?.setFromDefault) + continue + if (parse.output.metadata.flags && parse.output.metadata.flags[name]?.setFromDefault) + continue + if (parse.output.flags[flag]) { + return {...base, status: 'failed', reason: `--${flag}=${parse.output.flags[flag]} cannot also be provided when using --${name}`} } } + + return {...base, status: 'success'} + } + + async function validateExactlyOne(name: string, flags: FlagRelationship[]): Promise { + const base = {name, validationFn: 'validateExactlyOne'} + const resolved = await resolveFlags(flags) + const keys = getPresentFlags(resolved) + for (const flag of keys) { + if (flag !== name && parse.output.flags[flag]) { + return {...base, status: 'failed', reason: `--${flag} cannot also be provided when using --${name}`} + } + } + + return {...base, status: 'success'} + } + + async function validateDependsOn(name: string, flags: FlagRelationship[]): Promise { + const base = {name, validationFn: 'validateDependsOn'} + const resolved = await resolveFlags(flags) + const foundAll = Object.values(resolved).every(Boolean) + if (!foundAll) { + const formattedFlags = Object.keys(resolved).map(f => `--${f}`).join(', ') + return {...base, status: 'failed', reason: `All of the following must be provided when using --${name}: ${formattedFlags}`} + } + + return {...base, status: 'success'} + } + + async function validateSome(name: string, flags: FlagRelationship[]): Promise { + const base = {name, validationFn: 'validateSome'} + const resolved = await resolveFlags(flags) + const foundAtLeastOne = Object.values(resolved).some(Boolean) + if (!foundAtLeastOne) { + const formattedFlags = Object.keys(resolved).map(f => `--${f}`).join(', ') + return {...base, status: 'failed', reason: `One of the following must be provided when using --${name}: ${formattedFlags}`} + } + + return {...base, status: 'success'} + } + + async function validateRelationships(name: string, flag: CompletableFlag): Promise { + if (!flag.relationships) return [] + const results = await Promise.all(flag.relationships.map(async relationship => { + const flags = relationship.flags ?? [] + const results = [] + switch (relationship.type) { + case 'all': + results.push(await validateDependsOn(name, flags)) + break + case 'some': + results.push(await validateSome(name, flags)) + break + case 'none': + results.push(await validateExclusive(name, flags)) + break + default: + break + } + + return results + })) + + return results.flat() } validateArgs() - validateFlags() + await validateFlags() } diff --git a/test/parser/parse.test.ts b/test/parser/parse.test.ts index 86266af2..49afcc9b 100644 --- a/test/parser/parse.test.ts +++ b/test/parser/parse.test.ts @@ -135,7 +135,7 @@ describe('parse', () => { } expect(message).to.equal( - 'Missing required flag:\n --myflag MYFLAG flag description\nSee more help with --help', + 'The following error occurred:\n Missing required flag myflag\nSee more help with --help', ) }) @@ -925,7 +925,7 @@ See more help with --help`) message = error.message } - expect(message).to.equal('--bar= must also be provided when using --foo=') + expect(message).to.equal('The following error occurred:\n All of the following must be provided when using --foo: --bar\nSee more help with --help') }) }) @@ -962,7 +962,7 @@ See more help with --help`) message = error.message } - expect(message).to.equal('--bar= cannot also be provided when using --foo=') + expect(message).to.equal('The following error occurred:\n --bar=b cannot also be provided when using --foo\nSee more help with --help') }) }) @@ -980,7 +980,7 @@ See more help with --help`) message = error.message } - expect(message).to.equal('Exactly one of the following must be provided: --bar, --foo') + expect(message).to.equal('The following error occurred:\n Exactly one of the following must be provided: --bar, --foo\nSee more help with --help') }) it('throws if multiple are set', async () => { @@ -996,7 +996,7 @@ See more help with --help`) message = error.message } - expect(message).to.equal('--bar= cannot also be provided when using --foo=') + expect(message).to.equal('The following errors occurred:\n --bar cannot also be provided when using --foo\n --foo cannot also be provided when using --bar\nSee more help with --help') }) it('succeeds if exactly one', async () => { @@ -1057,7 +1057,7 @@ See more help with --help`) message = error.message } - expect(message).to.equal('--else= cannot also be provided when using --foo=') + expect(message).to.equal('The following errors occurred:\n --else cannot also be provided when using --foo\n --foo cannot also be provided when using --else\nSee more help with --help') }) it('handles cross-references/pairings that don\'t make sense', async () => { @@ -1075,7 +1075,7 @@ See more help with --help`) message1 = error.message } - expect(message1).to.equal('--bar= cannot also be provided when using --foo=') + expect(message1).to.equal('The following error occurred:\n --bar cannot also be provided when using --foo\nSee more help with --help') let message2 = '' try { @@ -1086,7 +1086,7 @@ See more help with --help`) message2 = error.message } - expect(message2).to.equal('--else= cannot also be provided when using --bar=') + expect(message2).to.equal('The following error occurred:\n --else cannot also be provided when using --bar\nSee more help with --help') const out = await parse(['--foo', 'a', '--else', '4'], { flags: crazyFlags, diff --git a/test/parser/validate.test.ts b/test/parser/validate.test.ts index 8676a05b..a64123bc 100644 --- a/test/parser/validate.test.ts +++ b/test/parser/validate.test.ts @@ -1,4 +1,6 @@ +import * as assert from 'assert' import {expect} from 'chai' +import {CLIError} from '../../src/errors' import {validate} from '../../src/parser/validate' @@ -26,7 +28,7 @@ describe('validate', () => { '--': true, } - it('enforces exclusivity for flags', () => { + it('enforces exclusivity for flags', async () => { const output = { args: {}, argv: [], @@ -48,11 +50,17 @@ describe('validate', () => { }, } - // @ts-ignore - expect(validate.bind({input, output})).to.throw() + try { + // @ts-expect-error + await validate({input, output}) + assert.fail('should have thrown') + } catch (error) { + const err = error as CLIError + expect(err.message).to.include('--dessert=cheesecake cannot also be provided when using --dinner') + } }) - it('ignores exclusivity for defaulted flags', () => { + it('ignores exclusivity for defaulted flags', async () => { const output = { args: {}, argv: [], @@ -74,11 +82,11 @@ describe('validate', () => { }, } - // @ts-ignore - validate({input, output}) + // @ts-expect-error + await validate({input, output}) }) - it('allows zero for integer', () => { + it('allows zero for integer', async () => { const input = { argv: [], flags: { @@ -108,11 +116,11 @@ describe('validate', () => { }, } - // @ts-ignore - validate({input, output}) + // @ts-expect-error + await validate({input, output}) }) - it('throws when required flag is undefined', () => { + it('throws when required flag is undefined', async () => { const input = { argv: [], flags: { @@ -137,7 +145,716 @@ describe('validate', () => { }, } - // @ts-ignore - expect(validate.bind({input, output})).to.throw() + try { + // @ts-expect-error + await validate({input, output}) + assert.fail('should have thrown') + } catch (error) { + const err = error as CLIError + expect(err.message).to.include('Missing required flag') + } + }) + + describe('relationships', () => { + describe('type: all', () => { + it('should pass if all required flags are provided', async () => { + const input = { + argv: [], + flags: { + cookies: {input: [], name: 'cookies'}, + sprinkles: {input: [], name: 'sprinkles'}, + dessert: { + input: [], + name: 'dessert', + relationships: [ + { + type: 'all', + flags: ['cookies', 'sprinkles'], + }, + ], + }, + }, + args: [], + strict: true, + context: {}, + '--': true, + } + + const output = { + args: {}, + argv: [], + flags: {dessert: 'ice-cream', sprinkles: true, cookies: true}, + raw: [ + {type: 'flag', flag: 'dessert', input: 'ice-cream'}, + {type: 'flag', flag: 'sprinkles', input: true}, + {type: 'flag', flag: 'cookies', input: true}, + ], + metadata: {}, + } + + // @ts-expect-error + await validate({input, output}) + }) + + it('should exclude any flags whose when property resolves to false', async () => { + const input = { + argv: [], + flags: { + cookies: {input: [], name: 'cookies'}, + sprinkles: {input: [], name: 'sprinkles'}, + dessert: { + input: [], + name: 'dessert', + relationships: [ + { + type: 'all', + flags: [ + 'cookies', + {name: 'sprinkles', when: async () => Promise.resolve(false)}, + ], + }, + ], + }, + }, + args: [], + strict: true, + context: {}, + '--': true, + } + + const output = { + args: {}, + argv: [], + flags: {dessert: 'ice-cream', cookies: true}, + raw: [ + {type: 'flag', flag: 'dessert', input: 'ice-cream'}, + {type: 'flag', flag: 'cookies', input: true}, + ], + metadata: {}, + } + + // @ts-expect-error + await validate({input, output}) + }) + + it('should require all specified flags', async () => { + const input = { + argv: [], + flags: { + cookies: {input: [], name: 'cookies'}, + sprinkles: {input: [], name: 'sprinkles'}, + dessert: { + input: [], + name: 'dessert', + relationships: [ + { + type: 'all', + flags: ['cookies', 'sprinkles'], + }, + ], + }, + }, + args: [], + strict: true, + context: {}, + '--': true, + } + + const output = { + args: {}, + argv: [], + flags: {dessert: 'ice-cream'}, + raw: [{type: 'flag', flag: 'dessert', input: 'ice-cream'}], + metadata: {}, + } + + try { + // @ts-expect-error + await validate({input, output}) + assert.fail('should have thrown') + } catch (error) { + const err = error as CLIError + expect(err.message).to.include('All of the following must be provided when using --dessert: --cookies, --sprinkles') + } + }) + + it('should require all specified flags with when property that resolves to true', async () => { + const input = { + argv: [], + flags: { + cookies: {input: [], name: 'cookies'}, + sprinkles: {input: [], name: 'sprinkles'}, + birthday: {input: [], name: 'birthday'}, + dessert: { + input: [], + name: 'dessert', + relationships: [ + { + type: 'all', + flags: [ + 'cookies', + { + name: 'sprinkles', + when: async (flags: {birthday: boolean}) => Promise.resolve(flags.birthday), + }, + ], + }, + ], + }, + }, + args: [], + strict: true, + context: {}, + '--': true, + } + + const output = { + args: {}, + argv: [], + flags: {dessert: 'ice-cream', birthday: true}, + raw: [{type: 'flag', flag: 'dessert', input: 'ice-cream'}], + metadata: {}, + } + + try { + // @ts-expect-error + await validate({input, output}) + assert.fail('should have thrown') + } catch (error) { + const err = error as CLIError + expect(err.message).to.include('All of the following must be provided when using --dessert: --cookies, --sprinkles') + } + }) + + it('should require all specified flags with when property that resolves to false', async () => { + const input = { + argv: [], + flags: { + cookies: {input: [], name: 'cookies'}, + sprinkles: {input: [], name: 'sprinkles'}, + birthday: {input: [], name: 'birthday'}, + dessert: { + input: [], + name: 'dessert', + relationships: [ + { + type: 'all', + flags: [ + 'cookies', + { + name: 'sprinkles', + when: async (flags: {birthday: boolean}) => Promise.resolve(flags.birthday), + }, + ], + }, + ], + }, + }, + args: [], + strict: true, + context: {}, + '--': true, + } + + const output = { + args: {}, + argv: [], + flags: {dessert: 'ice-cream', birthday: false}, + raw: [{type: 'flag', flag: 'dessert', input: 'ice-cream'}], + metadata: {}, + } + + try { + // @ts-expect-error + await validate({input, output}) + assert.fail('should have thrown') + } catch (error) { + const err = error as CLIError + expect(err.message).to.include('All of the following must be provided when using --dessert: --cookies') + } + }) + }) + + describe('type: some', () => { + it('should pass if some of the specified flags are provided', async () => { + const input = { + argv: [], + flags: { + cookies: {input: [], name: 'cookies'}, + sprinkles: {input: [], name: 'sprinkles'}, + dessert: { + input: [], + name: 'dessert', + relationships: [ + { + type: 'some', + flags: ['cookies', 'sprinkles'], + }, + ], + }, + }, + args: [], + strict: true, + context: {}, + '--': true, + } + + const output = { + args: {}, + argv: [], + flags: {dessert: 'ice-cream', sprinkles: true}, + raw: [ + {type: 'flag', flag: 'dessert', input: 'ice-cream'}, + {type: 'flag', flag: 'sprinkles', input: true}, + ], + metadata: {}, + } + + // @ts-expect-error + await validate({input, output}) + }) + + it('should require some of the specified flags', async () => { + const input = { + argv: [], + flags: { + cookies: {input: [], name: 'cookies'}, + sprinkles: {input: [], name: 'sprinkles'}, + dessert: { + input: [], + name: 'dessert', + relationships: [ + { + type: 'some', + flags: ['cookies', 'sprinkles'], + }, + ], + }, + }, + args: [], + strict: true, + context: {}, + '--': true, + } + + const output = { + args: {}, + argv: [], + flags: {dessert: 'ice-cream'}, + raw: [{type: 'flag', flag: 'dessert', input: 'ice-cream'}], + metadata: {}, + } + + try { + // @ts-expect-error + await validate({input, output}) + assert.fail('should have thrown') + } catch (error) { + const err = error as CLIError + expect(err.message).to.include('One of the following must be provided when using --dessert: --cookies, --sprinkles') + } + }) + + it('should require some of the specified flags with when property that resolves to true', async () => { + const input = { + argv: [], + flags: { + cookies: {input: [], name: 'cookies'}, + sprinkles: {input: [], name: 'sprinkles'}, + birthday: {input: [], name: 'birthday'}, + dessert: { + input: [], + name: 'dessert', + relationships: [ + { + type: 'some', + flags: [ + 'cookies', + { + name: 'sprinkles', + when: async (flags: {birthday: boolean}) => Promise.resolve(flags.birthday), + }, + ], + }, + ], + }, + }, + args: [], + strict: true, + context: {}, + '--': true, + } + + const output = { + args: {}, + argv: [], + flags: {dessert: 'ice-cream', birthday: true}, + raw: [{type: 'flag', flag: 'dessert', input: 'ice-cream'}], + metadata: {}, + } + + try { + // @ts-expect-error + await validate({input, output}) + assert.fail('should have thrown') + } catch (error) { + const err = error as CLIError + expect(err.message).to.include('One of the following must be provided when using --dessert: --cookies, --sprinkles') + } + }) + + it('should require some of the specified flags with when property that resolves to false', async () => { + const input = { + argv: [], + flags: { + cookies: {input: [], name: 'cookies'}, + sprinkles: {input: [], name: 'sprinkles'}, + birthday: {input: [], name: 'birthday'}, + dessert: { + input: [], + name: 'dessert', + relationships: [ + { + type: 'some', + flags: [ + 'cookies', + { + name: 'sprinkles', + when: async (flags: {birthday: boolean}) => Promise.resolve(flags.birthday), + }, + ], + }, + ], + }, + }, + args: [], + strict: true, + context: {}, + '--': true, + } + + const output = { + args: {}, + argv: [], + flags: {dessert: 'ice-cream', birthday: false}, + raw: [{type: 'flag', flag: 'dessert', input: 'ice-cream'}], + metadata: {}, + } + + try { + // @ts-expect-error + await validate({input, output}) + assert.fail('should have thrown') + } catch (error) { + const err = error as CLIError + expect(err.message).to.include('One of the following must be provided when using --dessert: --cookies') + } + }) + }) + + describe('type: none', () => { + it('should pass if none of the specified flags are provided', async () => { + const input = { + argv: [], + flags: { + cookies: {input: [], name: 'cookies'}, + sprinkles: {input: [], name: 'sprinkles'}, + dessert: { + input: [], + name: 'dessert', + relationships: [ + { + type: 'none', + flags: ['cookies', 'sprinkles'], + }, + ], + }, + }, + args: [], + strict: true, + context: {}, + '--': true, + } + + const output = { + args: {}, + argv: [], + flags: {dessert: 'ice-cream'}, + raw: [ + {type: 'flag', flag: 'dessert', input: 'ice-cream'}, + ], + metadata: {}, + } + + // @ts-expect-error + await validate({input, output}) + }) + + it('should fail if the specified flags are provided', async () => { + const input = { + argv: [], + flags: { + cookies: {input: [], name: 'cookies'}, + sprinkles: {input: [], name: 'sprinkles'}, + dessert: { + input: [], + name: 'dessert', + relationships: [ + { + type: 'none', + flags: ['cookies', 'sprinkles'], + }, + ], + }, + }, + args: [], + strict: true, + context: {}, + '--': true, + } + + const output = { + args: {}, + argv: [], + flags: {dessert: 'ice-cream', sprinkles: true}, + raw: [ + {type: 'flag', flag: 'dessert', input: 'ice-cream'}, + {type: 'flag', flag: 'sprinkles', input: true}, + ], + metadata: { + flags: { + dessert: {setFromDefault: false}, + sprinkles: {setFromDefault: false}, + }, + }, + } + + try { + // @ts-expect-error + await validate({input, output}) + assert.fail('should have thrown') + } catch (error) { + const err = error as CLIError + expect(err.message).to.include('--sprinkles=true cannot also be provided when using --dessert') + } + }) + + it('should fail if the specified flags are provided with when property that resolves to true', async () => { + const input = { + argv: [], + flags: { + cookies: {input: [], name: 'cookies'}, + sprinkles: {input: [], name: 'sprinkles'}, + birthday: {input: [], name: 'birthday'}, + dessert: { + input: [], + name: 'dessert', + relationships: [ + { + type: 'none', + flags: [ + { + name: 'sprinkles', + when: async (flags: {birthday: boolean}) => Promise.resolve(flags.birthday), + }, + ], + }, + ], + }, + }, + args: [], + strict: true, + context: {}, + '--': true, + } + + const output = { + args: {}, + argv: [], + flags: {dessert: 'ice-cream', birthday: true, sprinkles: true}, + raw: [ + {type: 'flag', flag: 'dessert', input: 'ice-cream'}, + {type: 'flag', flag: 'sprinkles', input: true}, + {type: 'flag', flag: 'birthday', input: true}, + ], + metadata: { + flags: { + dessert: {setFromDefault: false}, + sprinkles: {setFromDefault: false}, + birthday: {setFromDefault: false}, + }, + }, + } + + try { + // @ts-expect-error + await validate({input, output}) + assert.fail('should have thrown') + } catch (error) { + const err = error as CLIError + expect(err.message).to.include('--sprinkles=true cannot also be provided when using --dessert') + } + }) + + it('should pass if the specified flags are provided with when property that resolves to false', async () => { + const input = { + argv: [], + flags: { + cookies: {input: [], name: 'cookies'}, + sprinkles: {input: [], name: 'sprinkles'}, + birthday: {input: [], name: 'birthday'}, + dessert: { + input: [], + name: 'dessert', + relationships: [ + { + type: 'none', + flags: [ + { + name: 'sprinkles', + when: async (flags: {birthday: boolean}) => Promise.resolve(flags.birthday), + }, + ], + }, + ], + }, + }, + args: [], + strict: true, + context: {}, + '--': true, + } + + const output = { + args: {}, + argv: [], + flags: {dessert: 'ice-cream', birthday: false, sprinkles: true}, + raw: [ + {type: 'flag', flag: 'dessert', input: 'ice-cream'}, + {type: 'flag', flag: 'sprinkles', input: true}, + {type: 'flag', flag: 'birthday', input: false}, + ], + metadata: { + flags: { + dessert: {setFromDefault: false}, + sprinkles: {setFromDefault: false}, + birthday: {setFromDefault: false}, + }, + }, + } + + // @ts-expect-error + await validate({input, output}) + }) + }) + + describe('mixed', () => { + const input = { + argv: [], + flags: { + cookies: {input: [], name: 'cookies'}, + sprinkles: {input: [], name: 'sprinkles'}, + cake: {input: [], name: 'cake'}, + brownies: {input: [], name: 'brownies'}, + pie: {input: [], name: 'pie'}, + fudge: {input: [], name: 'fudge'}, + cupcake: {input: [], name: 'cupcake'}, + muffin: {input: [], name: 'muffin'}, + scone: {input: [], name: 'scone'}, + dessert: { + input: [], + name: 'dessert', + relationships: [ + { + type: 'all', + flags: [ + 'cookies', + {name: 'sprinkles', when: async () => Promise.resolve(false)}, + {name: 'cake', when: async () => Promise.resolve(true)}, + ], + }, + { + type: 'some', + flags: [ + 'brownies', + {name: 'pie', when: async () => Promise.resolve(false)}, + {name: 'fudge', when: async () => Promise.resolve(true)}, + ], + }, + { + type: 'none', + flags: [ + 'cupcake', + {name: 'muffin', when: async () => Promise.resolve(false)}, + {name: 'scone', when: async () => Promise.resolve(true)}, + ], + }, + ], + }, + }, + args: [], + strict: true, + context: {}, + '--': true, + } + + it('should succeed', async () => { + const output = { + args: {}, + argv: [], + flags: { + dessert: 'ice-cream', + cookies: true, + brownies: true, + cake: true, + muffin: true, + }, + raw: [ + {type: 'flag', flag: 'dessert', input: 'ice-cream'}, + {type: 'flag', flag: 'cookies', input: true}, + {type: 'flag', flag: 'brownies', input: true}, + {type: 'flag', flag: 'cake', input: true}, + {type: 'flag', flag: 'muffin', input: true}, + ], + metadata: {}, + } + + // @ts-expect-error + await validate({input, output}) + }) + + it('should fail', async () => { + const output = { + args: {}, + argv: [], + flags: { + dessert: 'ice-cream', + sprinkles: true, + cake: true, + scone: true, + pie: true, + }, + raw: [ + {type: 'flag', flag: 'dessert', input: 'ice-cream'}, + {type: 'flag', flag: 'sprinkles', input: true}, + {type: 'flag', flag: 'cake', input: true}, + {type: 'flag', flag: 'scone', input: true}, + {type: 'flag', flag: 'pie', input: true}, + ], + metadata: {}, + } + + try { + // @ts-expect-error + await validate({input, output}) + assert.fail('should have thrown') + } catch (error) { + const err = error as CLIError + expect(err.message).to.include('All of the following must be provided when using --dessert: --cookies, --cake') + expect(err.message).to.include('--scone=true cannot also be provided when using --dessert') + expect(err.message).to.include('One of the following must be provided when using --dessert: --brownies, --fudge') + } + }) + }) }) })