Repository: oclif/core (ts)

Issue:
Make some of the optional flags required
**Is your feature request related to a problem? Please describe.**
I have many optional flags, but I want at least one of these to be required.

https://github.com/bader-nasser/pdftools#pdftools-extract:

```bash
pdftools extract -i <value> -o <value> [-D] [-s] [-c] [-f <value> | -p <value> | -d <value>] [-l <value>
    |  | ] [-q even|odd] [-r north|south|east|west|left|right|down] [-k]
```
I want to set one of flags: `-f`, `-l`, `-p` or `-d` to be required. By the way, `[-l <value>
    |  | ]` seems like a bug to me because I think it should look like `[-l <value> | -p <value> | -d <value>]` but I'm not sure!

**Describe the solution you'd like**
Maybe using a static property like:
```ts
static flagsRelations = FlagsRelations({
  required: {type: 'any', flags: [ 'firstPage', 'lastPage', 'pages', 'data' ]}
})
```

**Describe alternatives you've considered**
I tried to use `if` statements to make sure one of these flags is provided by the user!

```ts
firstPage = firstPage ?? lastPage;
lastPage = lastPage ?? firstPage;


if (firstPage && isUndefinedOrEmptyString(lastPage)) {
        lastPage = firstPage;
}


if (lastPage && isUndefinedOrEmptyString(firstPage)) {
        firstPage = lastPage;
}


if (
        isUndefinedOrEmptyString(firstPage) &&
        isUndefinedOrEmptyString(lastPage) &&
        isUndefinedOrEmptyString(pageRanges) &&
        isUndefinedOrEmptyString(data)
) {
        this.log(
                'This command is useless without using one of the following flags:',
        );
        this.log('--firstPage, --lastPage, --pageRanges, --data');
        this.log('See more help with --help');
} else {
       // ...
}
```


Pull request:
feat: add atLeastOne flag property
Add `atLeastOne` flag property to allow commands to validate that at least one of the specified flags must be provided

Closes #829 
@W-15187594@

Patch:
diff --git a/src/interfaces/parser.ts b/src/interfaces/parser.ts
index 0c6a20f..f88a566 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 9e2b32c..df6d1ff 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, unknown>): string[] =>
     Object.keys(flags).filter((key) => key !== undefined)
 
-  function validateAcrossFlags(flag: Flag<any>): Validation {
-    const base = {name: flag.name, validationFn: 'validateAcrossFlags'}
+  function validateExactlyOneAcrossFlags(flag: Flag<any>): 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<any>): 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<Validation> {
     const base = {name, validationFn: 'validateExclusive'}
     const resolved = await resolveFlags(flags)


Test patch:
diff --git a/test/parser/parse.test.ts b/test/parser/parse.test.ts
index 1a5cbd4..bb51f1b 100644
--- a/test/parser/parse.test.ts
+++ b/test/parser/parse.test.ts
@@ -1644,6 +1644,60 @@ 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(['--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')
+    })
+
+    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([], {