1// @effect-diagnostics floatingEffect:skip-file
2import { assert, describe, it } from "@effect/vitest"
3import { Effect, FileSystem, Layer, Path, Runtime, Stdio } from "effect"
4import { Argument, CliError, CliOutput, Command, Flag } from "effect/cli"
5import { toImpl } from "effect/cli/internal/command"
6import * as Lexer from "effect/cli/internal/lexer"
7import * as Parser from "effect/cli/internal/parser"
8import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"
9import * as MockTerminal from "./services/MockTerminal.ts"
10
11const FileSystemLayer = FileSystem.layerNoop({})
12const PathLayer = Path.layer
13const TerminalLayer = MockTerminal.layer
14const SpawnerLayer = Layer.succeed(
15 ChildProcessSpawner.ChildProcessSpawner,
16 ChildProcessSpawner.make(() => Effect.die("Not implemented"))
17)
18
19const TestLayer = Layer.mergeAll(
20 FileSystemLayer,
21 PathLayer,
22 TerminalLayer,
23 SpawnerLayer,
24 Stdio.layerTest({})
25)
26
27describe("Command errors", () => {
28 it("uses the UnknownSubcommand class name as its runtime tag", () => {
29 const error = new CliError.UnknownSubcommand({
30 subcommand: "deplyo",
31 suggestions: ["deploy"]
32 })
33
34 assert.strictEqual(error._tag as string, "UnknownSubcommand")
35 })
36
37 describe("parse", () => {
38 it.effect("fails with MissingOption when a required flag is absent", () =>
39 Effect.gen(function*() {
40 const command = Command.make("needs-value", {
41 value: Flag.String("value")
42 })
43
44 const parsedInput = yield* Parser.parseArgs(Lexer.lex([]), command)
45 const error = yield* Effect.flip(toImpl(command).parse(parsedInput))
46 assert.instanceOf(error, CliError.MissingOption)
47 assert.strictEqual(error.option, "value")
48 }).pipe(Effect.provide(TestLayer)))
49
50 it("throws DuplicateOption when shared parent and child flags reuse a name", () => {
51 const parent = Command.make("parent").pipe(
52 Command.withSharedFlags({
53 shared: Flag.String("shared")
54 })
55 )
56
57 const child = Command.make("child", {
58 shared: Flag.String("shared")
59 })
60
61 try {
62 parent.pipe(Command.withSubcommands([child]))
63 assert.fail("expected DuplicateOption to be thrown")
64 } catch (error) {
65 assert.instanceOf(error, CliError.DuplicateOption)
66 const duplicate = error as CliError.DuplicateOption
67 assert.strictEqual(duplicate.option, "shared")
68 assert.strictEqual(duplicate.parentCommand, "parent")
69 assert.strictEqual(duplicate.childCommand, "child")
70 }
71 })
72
73 it("allows parent local flags to reuse child flag names", () => {
74 const parent = Command.make("parent", {
75 shared: Flag.String("shared")
76 })
77
78 const child = Command.make("child", {
79 shared: Flag.String("shared")
80 })
81
82 try {
83 parent.pipe(Command.withSubcommands([child]))
84 } catch (error) {
85 assert.fail(`did not expect DuplicateOption: ${String(error)}`)
86 }
87 })
88
89 it.effect("accumulates multiple UnrecognizedOption errors", () =>
90 Effect.gen(function*() {
91 const command = Command.make("test", {
92 verbose: Flag.Boolean("verbose")
93 })
94
95 const parsedInput = yield* Parser.parseArgs(
96 Lexer.lex(["--unknown1", "--unknown2"]),
97 command
98 )
99
100 assert.isDefined(parsedInput.errors)
101 assert.strictEqual(parsedInput.errors!.length, 2)
102 assert.instanceOf(parsedInput.errors![0], CliError.UnrecognizedOption)
103 assert.instanceOf(parsedInput.errors![1], CliError.UnrecognizedOption)
104 }).pipe(Effect.provide(TestLayer)))
105
106 it.effect("accumulates UnknownSubcommand error with suggestions", () =>
107 Effect.gen(function*() {
108 const sub = Command.make("deploy")
109 const command = Command.make("app").pipe(
110 Command.withSubcommands([sub])
111 )
112
113 const parsedInput = yield* Parser.parseArgs(
114 Lexer.lex(["deplyo"]),
115 command
116 )
117
118 assert.isDefined(parsedInput.errors)
119 assert.strictEqual(parsedInput.errors!.length, 1)
120 assert.instanceOf(parsedInput.errors![0], CliError.UnknownSubcommand)
121
122 const error = parsedInput.errors![0] as CliError.UnknownSubcommand
123 assert.strictEqual(error.subcommand, "deplyo")
124 assert.isTrue(error.suggestions.includes("deploy"))
125 }).pipe(Effect.provide(TestLayer)))
126
127 it.effect("fails with UnexpectedArgument when a bounded variadic leaves operands", () =>
128 Effect.gen(function*() {
129 const command = Command.make("test", {
130 values: Argument.String("value").pipe(Argument.variadic({ max: 2 }))
131 })
132
133 const parsedInput = yield* Parser.parseArgs(
134 Lexer.lex(["one", "two", "three"]),
135 command
136 )
137 const error = yield* Effect.flip(toImpl(command).parse(parsedInput))
138
139 assert.instanceOf(error, CliError.UnexpectedArgument)
140 assert.deepStrictEqual(error.arguments, ["three"])
141 }).pipe(Effect.provide(TestLayer)))
142
143 it.effect("allows a bounded variadic to leave an operand for a following argument", () =>
144 Effect.gen(function*() {
145 const command = Command.make("test", {
146 values: Argument.String("value").pipe(Argument.variadic({ max: 2 })),
147 destination: Argument.String("destination")
148 })
149
150 const parsedInput = yield* Parser.parseArgs(
151 Lexer.lex(["one", "two", "destination"]),
152 command
153 )
154 const result = yield* toImpl(command).parse(parsedInput)
155
156 assert.deepStrictEqual(result, {
157 values: ["one", "two"],
158 destination: "destination"
159 })
160 }).pipe(Effect.provide(TestLayer)))
161
162 it.effect("fails with UnexpectedArgument when a fixed argument leaves operands", () =>
163 Effect.gen(function*() {
164 const command = Command.make("test", {
165 value: Argument.String("value")
166 })
167
168 const parsedInput = yield* Parser.parseArgs(
169 Lexer.lex(["one", "two"]),
170 command
171 )
172 const error = yield* Effect.flip(toImpl(command).parse(parsedInput))
173
174 assert.instanceOf(error, CliError.UnexpectedArgument)
175 assert.deepStrictEqual(error.arguments, ["two"])
176 }).pipe(Effect.provide(TestLayer)))
177 })
178
179 describe("error formatting", () => {
180 it("escapes control characters in an unrecognized flag", () => {
181 const formatter = CliOutput.defaultFormatter({ colors: false })
182 const error = new CliError.UnrecognizedOption({
183 option: "--foo\x1b]52;c;bWFsaWNpb3Vz\x07",
184 suggestions: []
185 })
186
187 assert.strictEqual(
188 formatter.formatCliError(error),
189 "Unrecognized flag: --foo\\x1b]52;c;bWFsaWNpb3Vz\\x07"
190 )
191 })
192
193 it("escapes control characters in an unknown subcommand with colors enabled", () => {
194 const formatter = CliOutput.defaultFormatter({ colors: true })
195 const error = new CliError.UnknownSubcommand({
196 subcommand: "deplyo\x1b]8;;https://example.com\x07",
197 suggestions: []
198 })
199
200 assert.strictEqual(
201 formatter.formatError(error),
202 `\n\x1b[1m\x1b[31mERROR\x1b[0m\n Unknown subcommand "deplyo\\x1b]8;;https://example.com\\x07"\x1b[0m`
203 )
204 })
205
206 it("escapes control characters in an invalid argument value without colors", () => {
207 const formatter = CliOutput.defaultFormatter({ colors: false })
208 const error = new CliError.InvalidValue({
209 option: "count",
210 value: "12\x1b]52;c;bWFsaWNpb3Vz\x07\x7f",
211 expected: "an integer",
212 kind: "argument"
213 })
214
215 assert.strictEqual(
216 formatter.formatErrors([error]),
217 `\nERROR\n Invalid value for argument <count>: "12\\x1b]52;c;bWFsaWNpb3Vz\\x07\\x7f". Expected: an integer`
218 )
219 })
220
221 it("preserves multi-line suggestion blocks", () => {
222 const formatter = CliOutput.defaultFormatter({ colors: false })
223 const errors = [
224 new CliError.UnrecognizedOption({
225 option: "--deplyo",
226 suggestions: ["--deploy"]
227 }),
228 new CliError.UnknownSubcommand({
229 subcommand: "usrs",
230 parent: ["app"],
231 suggestions: ["users"]
232 })
233 ]
234
235 assert.strictEqual(
236 formatter.formatErrors(errors),
237 [
238 "",
239 "ERRORS",
240 " Unrecognized flag: --deplyo",
241 "",
242 " Did you mean this?",
243 " --deploy",
244 " Unknown subcommand \"usrs\" for \"app\"",
245 "",
246 " Did you mean this?",
247 " users"
248 ].join("\n")
249 )
250 })
251
252 it("preserves line feeds and tabs in error messages", () => {
253 const formatter = CliOutput.defaultFormatter({ colors: false })
254 const error = new CliError.InvalidValue({
255 option: "count",
256 value: "twelve",
257 expected: "one line\n\tcontinuation",
258 kind: "argument"
259 })
260
261 assert.strictEqual(
262 formatter.formatCliError(error),
263 "Invalid value for argument <count>: \"twelve\". Expected: one line\n\tcontinuation"
264 )
265 })
266
267 it("formats single error with ERROR header", () => {
268 const formatter = CliOutput.defaultFormatter({ colors: false })
269 const error = new CliError.MissingOption({ option: "value" })
270
271 const output = formatter.formatErrors([error])
272
273 assert.isTrue(output.includes("ERROR"))
274 assert.isTrue(output.includes("Missing required flag"))
275 })
276
277 it("formats multiple errors with ERRORS header", () => {
278 const formatter = CliOutput.defaultFormatter({ colors: false })
279 const errors = [
280 new CliError.UnrecognizedOption({ option: "--foo", suggestions: [] }),
281 new CliError.UnrecognizedOption({ option: "--bar", suggestions: [] })
282 ]
283
284 const output = formatter.formatErrors(errors)
285
286 assert.isTrue(output.includes("ERRORS"))
287 assert.isTrue(output.includes("--foo"))
288 assert.isTrue(output.includes("--bar"))
289 })
290
291 it("returns empty string for empty array", () => {
292 const formatter = CliOutput.defaultFormatter({ colors: false })
293 const output = formatter.formatErrors([])
294 assert.strictEqual(output, "")
295 })
296 })
297
298 describe("UserError", () => {
299 it("prefers the user-facing message over the cause", () => {
300 const error = new CliError.UserError({
301 cause: new Error("internal details"),
302 userMessage: "Could not deploy the application"
303 })
304
305 assert.strictEqual(error.message, "Could not deploy the application")
306 })
307
308 it("uses an Error cause message as the fallback", () => {
309 const error = new CliError.UserError({
310 cause: new Error("Connection refused")
311 })
312
313 assert.strictEqual(error.message, "Connection refused")
314 })
315
316 it("uses a string cause as the fallback", () => {
317 const error = new CliError.UserError({ cause: "Connection refused" })
318
319 assert.strictEqual(error.message, "Connection refused")
320 })
321
322 it("uses a generic fallback for causes without a message", () => {
323 const error = new CliError.UserError({ cause: { status: 503 } })
324
325 assert.strictEqual(error.message, "An error occurred")
326 })
327
328 it("falls back past empty user-facing and cause messages", () => {
329 const emptyUserMessage = new CliError.UserError({
330 cause: new Error("Connection refused"),
331 userMessage: ""
332 })
333 const emptyCause = new CliError.UserError({ cause: "" })
334
335 assert.strictEqual(emptyUserMessage.message, "Connection refused")
336 assert.strictEqual(emptyCause.message, "An error occurred")
337 })
338
339 it("allows runtime reporting before the CLI runner renders it", () => {
340 const error = new CliError.UserError({ cause: "failed" })
341
342 assert.isTrue(Runtime.getErrorReported(error))
343 })
344
345 it("escapes control characters in the user-facing message", () => {
346 const formatter = CliOutput.defaultFormatter({ colors: false })
347 const error = new CliError.UserError({
348 cause: "internal details",
349 userMessage: "Deployment failed\x1b]52;c;bWFsaWNpb3Vz\x07"
350 })
351
352 assert.strictEqual(
353 formatter.formatError(error),
354 "\nERROR\n Deployment failed\\x1b]52;c;bWFsaWNpb3Vz\\x07"
355 )
356 })
357
358 it("formats the resolved fallback message with other CLI errors", () => {
359 const formatter = CliOutput.defaultFormatter({ colors: false })
360 const error = new CliError.UserError({ cause: new Error("Connection refused") })
361
362 assert.strictEqual(formatter.formatErrors([error]), "\nERROR\n Connection refused")
363 })
364 })
365
366 describe("InvalidValue", () => {
367 it("labels a bare expected description", () => {
368 const error = new CliError.InvalidValue({
369 option: "size",
370 value: "bogus",
371 expected: `"small" | "medium" | "large"`,
372 kind: "flag"
373 })
374
375 assert.strictEqual(
376 error.message,
377 `Invalid value for flag --size: "bogus". Expected: "small" | "medium" | "large"`
378 )
379 })
380
381 it("does not double the prefix for an Expected sentence", () => {
382 const error = new CliError.InvalidValue({
383 option: "count",
384 value: "3.14",
385 expected: "Expected an integer, got 3.14",
386 kind: "argument"
387 })
388
389 assert.strictEqual(
390 error.message,
391 `Invalid value for argument <count>: "3.14". Expected an integer, got 3.14`
392 )
393
394 const labeled = new CliError.InvalidValue({
395 option: "count",
396 value: "x",
397 expected: "Expected: an integer",
398 kind: "flag"
399 })
400
401 assert.strictEqual(
402 labeled.message,
403 `Invalid value for flag --count: "x". Expected: an integer`
404 )
405 })
406
407 it("does not double the prefix for a missing flag value", () => {
408 const error = new CliError.InvalidValue({
409 option: "count",
410 value: "",
411 expected: `Expected a string representing a finite number, got ""`,
412 kind: "flag"
413 })
414
415 assert.strictEqual(
416 error.message,
417 `Missing value for flag --count. Expected a string representing a finite number, got ""`
418 )
419 })
420 })
421
422 describe("UnexpectedArgument", () => {
423 it("formats one or more unexpected positional arguments", () => {
424 const single = new CliError.UnexpectedArgument({
425 arguments: ["extra"]
426 })
427 const multiple = new CliError.UnexpectedArgument({
428 arguments: ["first", "second"]
429 })
430
431 assert.strictEqual(single.message, `Unexpected positional argument: "extra"`)
432 assert.strictEqual(multiple.message, `Unexpected positional arguments: "first", "second"`)
433 })
434 })
435})