1import { assert, describe, expect, it } from "@effect/vitest"
2import { Effect, FileSystem, Layer, Option, Path, PlatformError, Ref, Result, Stdio } from "effect"
3import { Argument, CliOutput, Command, Flag } from "effect/cli"
4import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"
5import { TestConsole } from "effect/testing"
6import * as MockTerminal from "./services/MockTerminal.ts"
7
8const ConsoleLayer = TestConsole.layer
9const FileSystemLayer = FileSystem.layerNoop({
10 exists: (path) =>
11 path.includes("/non/existent/file.txt")
12 ? Effect.succeed(false)
13 : Effect.succeed(true),
14 stat: (path) => {
15 if (path.includes("/non/existent/file.txt")) {
16 return Effect.fail(PlatformError.badArgument({ module: "", method: "" }))
17 }
18 if (path.endsWith("workspace")) {
19 return Effect.succeed({ type: "Directory" } as any)
20 }
21 return Effect.succeed({ type: "File" } as any)
22 },
23 access: (path) =>
24 path.includes("/non/existent/file.txt")
25 ? Effect.fail(PlatformError.badArgument({ module: "", method: "" }))
26 : Effect.void
27})
28const PathLayer = Path.layer
29const TerminalLayer = MockTerminal.layer
30const CliOutputLayer = CliOutput.layer(
31 CliOutput.defaultFormatter({
32 colors: false
33 })
34)
35const StdioLayer = Stdio.layerTest({})
36const ChildProcessSpawnerLayer = Layer.succeed(
37 ChildProcessSpawner.ChildProcessSpawner,
38 ChildProcessSpawner.make(() => Effect.die("Not implemented"))
39)
40
41const TestLayer = Layer.mergeAll(
42 ConsoleLayer,
43 FileSystemLayer,
44 PathLayer,
45 TerminalLayer,
46 CliOutputLayer,
47 StdioLayer,
48 ChildProcessSpawnerLayer
49)
50
51describe("Command arguments", () => {
52 it.effect("should parse all argument types correctly", () =>
53 Effect.gen(function*() {
54 // Create a Ref to store the result
55 const resultRef = yield* Ref.make<any>(null)
56
57 // Create test command with various argument types
58 const testCommand = Command.make("test", {
59 name: Argument.String("name"),
60 count: Argument.Int("count"),
61 ratio: Argument.Finite("ratio"),
62 env: Argument.Literals("env", ["dev", "prod"]),
63 config: Argument.File("config", { mustExist: false }),
64 workspace: Argument.Directory("workspace", { mustExist: false }),
65 startDate: Argument.Date("start-date"),
66 verbose: Flag.Boolean("verbose")
67 }, (config) => Ref.set(resultRef, config))
68
69 // Test parsing with valid arguments
70 yield* Command.runWith(testCommand, { version: "1.0.0" })([
71 "myapp", // name
72 "42", // count
73 "3.14", // ratio
74 "dev", // env
75 "./config.json", // config
76 "./workspace", // workspace
77 "2024-01-01", // startDate
78 "--verbose" // flag
79 ])
80
81 const result = yield* Ref.get(resultRef)
82 assert.strictEqual(result.name, "myapp")
83 assert.strictEqual(result.count, 42)
84 assert.strictEqual(result.ratio, 3.14)
85 assert.strictEqual(result.env, "dev")
86 assert.isTrue(result.config.includes("config.json"))
87 // assert.isTrue(result.workspace.includes("workspace"))
88 assert.deepStrictEqual(result.startDate, new Date("2024-01-01"))
89 assert.strictEqual(result.verbose, true)
90 }).pipe(Effect.provide(TestLayer)))
91
92 it.effect("should handle file mustExist validation", () =>
93 Effect.gen(function*() {
94 // Test 1: mustExist: true with existing file - should pass
95 const result1Ref = yield* Ref.make<string | null>(null)
96 const existingFileCommand = Command.make("test", {
97 file: Argument.File("file", { mustExist: true })
98 }, ({ file }) => Ref.set(result1Ref, file))
99
100 yield* Command.runWith(existingFileCommand, { version: "1.0.0" })(["/file.txt"])
101 const result1 = yield* Ref.get(result1Ref)
102 assert.strictEqual(result1, "/file.txt")
103
104 // Test 2: mustExist: true with non-existing file - should display error and help
105 const runCommand = Command.runWith(existingFileCommand, { version: "1.0.0" })
106 yield* runCommand(["/non/existent/file.txt"]).pipe(
107 Effect.catchTag("ShowHelp", () => Effect.void)
108 )
109
110 // Check that help was shown
111 const stdout = yield* TestConsole.logLines
112 assert.isTrue(stdout.some((line) => String(line).includes("USAGE")))
113
114 // Check that error was shown
115 const stderr = yield* TestConsole.errorLines
116 assert.isTrue(stderr.some((line) => String(line).includes("ERROR")))
117 assert.isTrue(stderr.some((line) => String(line).includes("does not exist")))
118
119 // Test 3: mustExist: false - should always pass
120 const result3Ref = yield* Ref.make<string | null>(null)
121 const optionalFileCommand = Command.make("test", {
122 file: Argument.File("file", { mustExist: false })
123 }, ({ file }) => Ref.set(result3Ref, file))
124
125 yield* Command.runWith(optionalFileCommand, { version: "1.0.0" })([
126 "/non/existent/file.txt"
127 ])
128 const result3 = yield* Ref.get(result3Ref)
129 assert.isTrue(result3!.includes("/non/existent/file.txt"))
130 }).pipe(Effect.provide(TestLayer)))
131
132 it.effect("should fail with invalid arguments", () =>
133 Effect.gen(function*() {
134 const testCommand = Command.make("test", {
135 count: Argument.Int("count"),
136 env: Argument.Literals("env", ["dev", "prod"])
137 }, (config) => Effect.succeed(config))
138
139 // Test invalid integer - should display help and error
140 const runCommand = Command.runWith(testCommand, { version: "1.0.0" })
141 yield* runCommand(["not-a-number", "dev"]).pipe(
142 Effect.catchTag("ShowHelp", () => Effect.void)
143 )
144
145 // Check help was shown
146 const stdout = yield* TestConsole.logLines
147 const helpText = stdout.join("\n")
148 expect(helpText).toContain("USAGE")
149 expect(helpText).toContain("test [flags] <count> <env>")
150 expect(helpText).toContain("count integer")
151 expect(helpText).toContain("env choice")
152
153 // Check error was shown
154 const stderr = yield* TestConsole.errorLines
155 const errorText = stderr.join("\n")
156 expect(errorText).toMatchInlineSnapshot(`
157 "
158 ERROR
159 Invalid value for argument <count>: "not-a-number". Expected a string representing a finite number"
160 `)
161 }).pipe(Effect.provide(TestLayer)))
162
163 it.effect("should handle variadic arguments", () =>
164 Effect.gen(function*() {
165 let result: { readonly files: ReadonlyArray<string> } | undefined
166
167 const testCommand = Command.make("test", {
168 files: Argument.variadic(Argument.String("files"))
169 }, (parsedConfig) =>
170 Effect.sync(() => {
171 result = parsedConfig
172 }))
173
174 yield* Command.runWith(testCommand, { version: "1.0.0" })([
175 "file1.txt",
176 "file2.txt",
177 "file3.txt"
178 ])
179
180 assert.isDefined(result)
181 assert.deepStrictEqual(result.files, ["file1.txt", "file2.txt", "file3.txt"])
182 }).pipe(Effect.provide(TestLayer)))
183
184 it.effect("should handle choiceWithValue", () =>
185 Effect.gen(function*() {
186 const resultRef = yield* Ref.make<any>(null)
187
188 const testCommand = Command.make("test", {
189 level: Argument.ChoiceWithValue(
190 "level",
191 [
192 ["debug", 0],
193 ["info", 1],
194 ["error", 2]
195 ] as const
196 )
197 }, (config) => Ref.set(resultRef, config))
198
199 yield* Command.runWith(testCommand, { version: "1.0.0" })(["info"])
200 const result = yield* Ref.get(resultRef)
201 assert.strictEqual(result.level, 1)
202 }).pipe(Effect.provide(TestLayer)))
203
204 it.effect("should handle filter combinator - valid", () =>
205 Effect.gen(function*() {
206 const resultRef = yield* Ref.make<any>(null)
207
208 const testCommand = Command.make("test", {
209 port: Argument.Int("port").pipe(
210 Argument.filter(
211 (n) => n >= 1 && n <= 65535,
212 (n) => `Port ${n} out of range (1-65535)`
213 )
214 )
215 }, (config) => Ref.set(resultRef, config))
216
217 yield* Command.runWith(testCommand, { version: "1.0.0" })(["8080"])
218 const result = yield* Ref.get(resultRef)
219 assert.strictEqual(result.port, 8080)
220 }).pipe(Effect.provide(TestLayer)))
221
222 it.effect("should handle filter combinator - invalid", () =>
223 Effect.gen(function*() {
224 const testCommand = Command.make("test", {
225 port: Argument.Int("port").pipe(
226 Argument.filter(
227 (n) => n >= 1 && n <= 65535,
228 (n) => `Port ${n} out of range (1-65535)`
229 )
230 )
231 }, () => Effect.void)
232
233 yield* Command.runWith(testCommand, { version: "1.0.0" })(["99999"]).pipe(
234 Effect.catchTag("ShowHelp", () => Effect.void)
235 )
236 const stderr = yield* TestConsole.errorLines
237 assert.isTrue(stderr.some((line) => String(line).includes("out of range")))
238 }).pipe(Effect.provide(TestLayer)))
239
240 it.effect("should handle filterMap combinator - valid", () =>
241 Effect.gen(function*() {
242 const resultRef = yield* Ref.make<any>(null)
243
244 const testCommand = Command.make("test", {
245 positiveInt: Argument.Int("num").pipe(
246 Argument.filterMap(
247 (n) => n > 0 ? Option.some(n) : Option.none(),
248 (n) => `Expected positive integer, got ${n}`
249 )
250 )
251 }, (config) => Ref.set(resultRef, config))
252
253 yield* Command.runWith(testCommand, { version: "1.0.0" })(["42"])
254 const result = yield* Ref.get(resultRef)
255 assert.strictEqual(result.positiveInt, 42)
256 }).pipe(Effect.provide(TestLayer)))
257
258 it.effect("should handle filterMap combinator - invalid", () =>
259 Effect.gen(function*() {
260 const testCommand = Command.make("test", {
261 positiveInt: Argument.Int("num").pipe(
262 Argument.filterMap(
263 (n) => n > 0 ? Option.some(n) : Option.none(),
264 (n) => `Expected positive integer, got ${n}`
265 )
266 )
267 }, () => Effect.void)
268
269 yield* Command.runWith(testCommand, { version: "1.0.0" })(["0"]).pipe(
270 Effect.catchTag("ShowHelp", () => Effect.void)
271 )
272 const stderr = yield* TestConsole.errorLines
273 assert.isTrue(stderr.some((line) => String(line).includes("Expected positive integer")))
274 }).pipe(Effect.provide(TestLayer)))
275
276 it.effect("should handle orElse combinator", () =>
277 Effect.gen(function*() {
278 const resultRef = yield* Ref.make<any>(null)
279
280 // Try parsing as integer first, fallback to 0
281 const testCommand = Command.make("test", {
282 value: Argument.Int("value").pipe(
283 Argument.orElse(() => Argument.String("value").pipe(Argument.map(() => -1)))
284 )
285 }, (config) => Ref.set(resultRef, config))
286
287 // Valid integer
288 yield* Command.runWith(testCommand, { version: "1.0.0" })(["42"])
289 let result = yield* Ref.get(resultRef)
290 assert.strictEqual(result.value, 42)
291
292 // Invalid integer, falls back to string path
293 yield* Ref.set(resultRef, null)
294 yield* Command.runWith(testCommand, { version: "1.0.0" })(["abc"])
295 result = yield* Ref.get(resultRef)
296 assert.strictEqual(result.value, -1)
297 }).pipe(Effect.provide(TestLayer)))
298
299 it.effect("should handle orElseResult combinator", () =>
300 Effect.gen(function*() {
301 const resultRef = yield* Ref.make<any>(null)
302
303 const testCommand = Command.make("test", {
304 value: Argument.Int("value").pipe(
305 Argument.orElseResult(() => Argument.String("value"))
306 )
307 }, (config) => Ref.set(resultRef, config))
308
309 // Valid integer - returns Success
310 yield* Command.runWith(testCommand, { version: "1.0.0" })(["42"])
311 let result = yield* Ref.get(resultRef)
312 assert.isTrue(Result.isSuccess(result.value))
313 assert.strictEqual(result.value.success, 42)
314
315 // Invalid integer - returns Failure with string
316 yield* Ref.set(resultRef, null)
317 yield* Command.runWith(testCommand, { version: "1.0.0" })(["abc"])
318 result = yield* Ref.get(resultRef)
319 assert.isTrue(Result.isFailure(result.value))
320 assert.strictEqual(result.value.failure, "abc")
321 }).pipe(Effect.provide(TestLayer)))
322
323 it.effect("should handle withMetavar combinator", () =>
324 Effect.gen(function*() {
325 const testCommand = Command.make("test", {
326 file: Argument.String("file").pipe(
327 Argument.withMetavar("FILE_PATH")
328 )
329 }, () => Effect.void)
330
331 // Run with help flag to check pseudo name appears in help
332 yield* Command.runWith(testCommand, { version: "1.0.0" })(["--help"])
333 const stdout = yield* TestConsole.logLines
334 const helpText = stdout.join("\n")
335 assert.isTrue(helpText.includes("FILE_PATH"))
336 }).pipe(Effect.provide(TestLayer)))
337
338 it.effect("should handle optional arguments - when provided", () =>
339 Effect.gen(function*() {
340 const resultRef = yield* Ref.make<any>(null)
341
342 const testCommand = Command.make("test", {
343 label: Argument.optional(
344 Argument.String("label").pipe(
345 Argument.withDescription("Optional label name")
346 )
347 )
348 }, (config) => Ref.set(resultRef, config))
349
350 // When optional argument IS provided
351 yield* Command.runWith(testCommand, { version: "1.0.0" })(["my-label"])
352 const result = yield* Ref.get(resultRef)
353 assert.isTrue(Option.isSome(result.label))
354 assert.strictEqual(result.label.value, "my-label")
355 }).pipe(Effect.provide(TestLayer)))
356
357 it.effect("returns none for omitted optional positional arguments", () =>
358 Effect.gen(function*() {
359 // BUG TEST: Argument.optional() should work for positional arguments
360 // Currently it only catches MissingOption, not MissingArgument
361 const optionalArg = Argument.optional(Argument.String("label"))
362
363 // Parse with empty arguments - should succeed with Option.none()
364 const result = yield* optionalArg.parse({ flags: {}, arguments: [] })
365
366 // If we get here without error, optional is working
367 const [_leftover, value] = result
368 assert.isTrue(Option.isNone(value), "Should be Option.none()")
369 }).pipe(Effect.provide(TestLayer)))
370
371 it.effect("uses argument wording for positional InvalidValue errors", () =>
372 Effect.gen(function*() {
373 // When a positional argument has an invalid value, the error should say "argument"
374 // not "flag" (which would be confusing)
375 const intArg = Argument.Int("count")
376
377 const result = yield* Effect.exit(
378 intArg.parse({ flags: {}, arguments: ["not-a-number"] })
379 )
380
381 assert.isTrue(result._tag === "Failure", "Should fail with invalid integer")
382 if (result._tag === "Failure") {
383 // Extract the error from the cause
384 const cause = result.cause
385 // The cause structure is { _id: 'Cause', failures: [...] } but we need to access it properly
386 const errorMessage = String(cause)
387
388 // Error message should NOT say "flag"
389 assert.isFalse(
390 errorMessage.includes("flag --"),
391 `Error message should not say 'flag --' for positional arguments: ${errorMessage}`
392 )
393 // Error message SHOULD say "argument"
394 assert.isTrue(
395 errorMessage.includes("argument"),
396 `Error message should say 'argument': ${errorMessage}`
397 )
398 }
399 }).pipe(Effect.provide(TestLayer)))
400})