Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions src/commands/errors.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* Error codes thrown by `@doist/cli-core/commands` registration helpers. Folded
* into the `CliErrorCode` aggregator in `../errors.ts` so consumers don't have
* to redeclare them in their own `TCode` union when catching.
* Error codes thrown by shared command and output-option helpers. Folded into
* the `CliErrorCode` aggregator in `../errors.ts` so consumers don't have to
* redeclare them in their own `TCode` union when catching.
*/
export type CommandErrorCode =
| 'CONFLICTING_OPTIONS'
| 'INVALID_TYPE'
| 'FILE_READ_ERROR'
| 'INVALID_FLAGS'
Expand Down
10 changes: 7 additions & 3 deletions src/empty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ describeEmptyMachineOutput('printEmpty (contract via describeEmptyMachineOutput)
run: async (extraArgs) => {
printEmpty({
options: {
idsOnly: extraArgs.includes('--ids-only'),
json: extraArgs.includes('--json'),
ndjson: extraArgs.includes('--ndjson'),
},
message: HUMAN_MESSAGE,
})
},
humanMessage: HUMAN_MESSAGE,
idsOnly: true,
})

describe('printEmpty (extras)', () => {
Expand Down Expand Up @@ -46,8 +48,10 @@ describe('printEmpty (extras)', () => {
writeSpy = undefined
})

it('prefers --json over --ndjson when both flags are set', () => {
printEmpty({ options: { json: true, ndjson: true }, message: 'unused' })
expect(captured).toBe('[]\n')
it('rejects conflicting output modes', () => {
expect(() =>
printEmpty({ options: { json: true, ndjson: true }, message: 'unused' }),
).toThrow('Options --json, --ndjson are mutually exclusive.')
expect(captured).toBe('')
})
})
18 changes: 13 additions & 5 deletions src/empty.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
import { formatJson } from './json.js'
import type { ViewOptions } from './options.js'
import { type ListViewOptions, resolveOutputMode } from './options.js'

/**
* Gate the empty-state print on the active output mode:
* --json → prints exactly `'[]'`
* --ndjson → prints nothing (no stray newline; ndjson EOF = end of stream)
* --ids-only → prints nothing
* neither → prints the human-readable message
*
* Use at every list/array empty-result branch so machine consumers never see
* human strings on stdout when they asked for `--json` / `--ndjson`.
* human strings on stdout when they asked for a machine-output mode.
*/
export function printEmpty({ options, message }: { options: ViewOptions; message: string }): void {
if (options.json) {
export function printEmpty({
options,
message,
}: {
options: ListViewOptions
message: string
}): void {
const outputMode = resolveOutputMode(options)
if (outputMode === 'json') {
console.log(formatJson([]))
return
}
if (options.ndjson) {
if (outputMode === 'ndjson' || outputMode === 'ids-only') {
return
}
console.log(message)
Expand Down
3 changes: 3 additions & 0 deletions src/global-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
describe('parseGlobalArgs', () => {
it('defaults every field to false/0', () => {
expect(parseGlobalArgs([])).toEqual({
idsOnly: false,
json: false,
ndjson: false,
quiet: false,
Expand All @@ -25,6 +26,7 @@ describe('parseGlobalArgs', () => {
})

it.each([
['--ids-only', 'idsOnly', true],
['--json', 'json', true],
['--ndjson', 'ndjson', true],
['--quiet', 'quiet', true],
Expand Down Expand Up @@ -266,6 +268,7 @@ describe('createSpinnerGate', () => {
})

it.each([
['--ids-only'],
['--json'],
['--ndjson'],
['--no-spinner'],
Expand Down
12 changes: 8 additions & 4 deletions src/global-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
* twist's `--non-interactive`) can layer their own fields over `GlobalArgs`.
*/

import type { ViewOptions } from './options.js'
import type { ListViewOptions } from './options.js'
import { isCI } from './terminal.js'

export type GlobalArgs = Required<Pick<ViewOptions, 'json' | 'ndjson'>> & {
export type GlobalArgs = Required<Pick<ListViewOptions, 'idsOnly' | 'json' | 'ndjson'>> & {
quiet: boolean
verbose: 0 | 1 | 2 | 3 | 4
accessible: boolean
Expand Down Expand Up @@ -77,6 +77,7 @@ export function parseGlobalArgs(argv?: string[]): GlobalArgs {
const args = argv ?? process.argv.slice(2)

const result: GlobalArgs = {
idsOnly: false,
json: false,
ndjson: false,
quiet: false,
Expand All @@ -91,7 +92,9 @@ export function parseGlobalArgs(argv?: string[]): GlobalArgs {

if (arg === '--') break

if (arg === '--json') {
if (arg === '--ids-only') {
result.idsOnly = true
} else if (arg === '--json') {
result.json = true
} else if (arg === '--ndjson') {
result.ndjson = true
Expand Down Expand Up @@ -256,7 +259,7 @@ export type SpinnerGateOptions = {
* Build a `shouldDisableSpinner` predicate. Disables on:
* - env var equals `'false'`
* - `isCI()`
* - any of `--json`, `--ndjson`, `--no-spinner`, `--progress-jsonl`, `--verbose`
* - any of `--json`, `--ndjson`, `--ids-only`, `--no-spinner`, `--progress-jsonl`, `--verbose`
* - `extraTriggers?.()` returning true
*
* Pair with `createSpinner({ isDisabled })` from `./spinner.js`.
Expand Down Expand Up @@ -288,6 +291,7 @@ export function createSpinnerGate(opts: SpinnerGateOptions): () => boolean {
if (
args.json ||
args.ndjson ||
args.idsOnly ||
args.noSpinner ||
isProgressJsonlEnabled(args) ||
args.verbose > 0
Expand Down
43 changes: 43 additions & 0 deletions src/ids.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { formatIds, outputIds } from './ids.js'

describe('formatIds', () => {
it('formats string and numeric IDs one per line', () => {
expect(formatIds([{ id: 'task-1' }, { id: 42 }], (item) => item.id)).toBe('task-1\n42')
})

it('returns an empty string for no results', () => {
expect(formatIds([], (item: { id: string }) => item.id)).toBe('')
})
})

describe('outputIds', () => {
afterEach(() => {
vi.restoreAllMocks()
})

it('writes IDs to stdout in one block', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})

outputIds([{ id: 'a' }, { id: 'b' }], (item) => item.id)

expect(log).toHaveBeenCalledOnce()
expect(log).toHaveBeenCalledWith('a\nb')
})

it('writes nothing to stdout for no results', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})

outputIds([], (item: { id: string }) => item.id)

expect(log).not.toHaveBeenCalled()
})

it('writes pagination notices to stderr', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})

outputIds([], (item: { id: string }) => item.id, 'More results exist.')

expect(error).toHaveBeenCalledWith('More results exist.')
})
})
18 changes: 18 additions & 0 deletions src/ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** Format stable IDs as one value per line, with no trailing newline. */
export function formatIds<T>(items: readonly T[], getId: (item: T) => string | number): string {
return items.map((item) => String(getId(item))).join('\n')
}

/**
* Write stable IDs to stdout and an optional pagination notice to stderr.
* Empty results write nothing to stdout.
*/
export function outputIds<T>(
items: readonly T[],
getId: (item: T) => string | number,
paginationNotice = '',
): void {
const output = formatIds(items, getId)
if (output) console.log(output)
if (paginationNotice) console.error(paginationNotice)
}
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ export type {
SpinnerGateOptions,
} from './global-args.js'
export { formatJson, formatNdjson } from './json.js'
export { emitView } from './options.js'
export type { ViewOptions } from './options.js'
export { formatIds, outputIds } from './ids.js'
export { emitView, OUTPUT_MODES, resolveOutputMode } from './options.js'
export type { ListViewOptions, OutputMode, ViewOptions } from './options.js'
export { createSpinner } from './spinner.js'
export type {
LoadingSpinner,
Expand Down
39 changes: 37 additions & 2 deletions src/options.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expectTypeOf, it } from 'vitest'
import type { ViewOptions } from './options.js'
import { describe, expect, expectTypeOf, it } from 'vitest'
import { type ListViewOptions, type ViewOptions, resolveOutputMode } from './options.js'

describe('ViewOptions', () => {
it('declares json and ndjson as optional booleans', () => {
Expand All @@ -19,3 +19,38 @@ describe('ViewOptions', () => {
expectTypeOf(narrow).toMatchTypeOf<ViewOptions>()
})
})

describe('ListViewOptions', () => {
it('adds the optional IDs-only flag to the canonical view options', () => {
const opts: ListViewOptions = { idsOnly: true, json: false, ndjson: false }
expectTypeOf(opts).toMatchTypeOf<{
idsOnly?: boolean
json?: boolean
ndjson?: boolean
}>()
})
})

describe('resolveOutputMode', () => {
it.each([
[{}, 'human'],
[{ json: true }, 'json'],
[{ ndjson: true }, 'ndjson'],
[{ idsOnly: true }, 'ids-only'],
] as const)('resolves %o to %s', (options, expected) => {
expect(resolveOutputMode(options)).toBe(expected)
})

it.each([
[{ json: true, ndjson: true }, 'Options --json, --ndjson are mutually exclusive.'],
[{ json: true, idsOnly: true }, 'Options --json, --ids-only are mutually exclusive.'],
[{ ndjson: true, idsOnly: true }, 'Options --ndjson, --ids-only are mutually exclusive.'],
])('rejects conflicting output flags in %o', (options, message) => {
try {
resolveOutputMode(options)
expect.fail('Expected conflicting output flags to throw')
} catch (error) {
expect(error).toMatchObject({ code: 'CONFLICTING_OPTIONS', message })
}
})
})
34 changes: 34 additions & 0 deletions src/options.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { CliError } from './errors.js'
import { formatJson, formatNdjson } from './json.js'

/** Canonical output modes shared by Doist CLIs. */
export const OUTPUT_MODES = ['human', 'json', 'ndjson', 'ids-only'] as const

/** A canonical output mode shared by Doist CLIs. */
export type OutputMode = (typeof OUTPUT_MODES)[number]

/**
* Shared shape for commands that respect the canonical machine-output flags.
* Seeded narrow so the type only declares what cli-core helpers actually read
Expand All @@ -14,6 +21,33 @@ export type ViewOptions = {
ndjson?: boolean
}

/** Shared shape for list commands that can emit only stable result IDs. */
export type ListViewOptions = ViewOptions & {
idsOnly?: boolean
}

const OUTPUT_FLAGS: ReadonlyArray<{
enabled: (options: ListViewOptions) => boolean
flag: string
mode: Exclude<OutputMode, 'human'>
}> = [
{ enabled: (options) => Boolean(options.json), flag: '--json', mode: 'json' },
{ enabled: (options) => Boolean(options.ndjson), flag: '--ndjson', mode: 'ndjson' },
{ enabled: (options) => Boolean(options.idsOnly), flag: '--ids-only', mode: 'ids-only' },
]

/** Resolve the selected canonical output mode and reject conflicting flags. */
export function resolveOutputMode(options: ListViewOptions): OutputMode {
const selected = OUTPUT_FLAGS.filter(({ enabled }) => enabled(options))
if (selected.length > 1) {
throw new CliError(
'CONFLICTING_OPTIONS',
`Options ${selected.map(({ flag }) => flag).join(', ')} are mutually exclusive.`,
)
}
return selected[0]?.mode ?? 'human'
}

/**
* `--json` / `--ndjson` / human emitter. `humanLines` is a thunk so the
* human-mode strings (chalk colouring, conditional formatting) are never
Expand Down
10 changes: 10 additions & 0 deletions src/testing/empty-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ type EmptyOutputConfig = {
setup: () => void | Promise<void>
run: (extraArgs: string[]) => Promise<void>
humanMessage: string | RegExp
/** Also assert the optional `--ids-only` empty-output contract. */
idsOnly?: boolean
}

/**
* Asserts the standard `printEmpty` contract for a command:
* --json → writes exactly `'[]\n'` to stdout
* --ndjson → writes nothing to stdout (no stray newline)
* --ids-only → writes nothing when `idsOnly` is enabled in the config
* neither → writes exactly the human message + `\n` to stdout
*
* Captures bytes from both `console.log` (which vitest intercepts before
Expand Down Expand Up @@ -56,6 +59,13 @@ export function describeEmptyMachineOutput(label: string, config: EmptyOutputCon
expect(captured).toBe('')
})

if (config.idsOnly) {
it('writes nothing to stdout for --ids-only', async () => {
await config.run(['--ids-only'])
expect(captured).toBe('')
})
}

it('writes exactly the human message to stdout when no machine flag is set', async () => {
await config.run([])
if (typeof config.humanMessage === 'string') {
Expand Down
Loading