import { assert, describe, expect, it } from "@effect/vitest" import { Effect, FileSystem, Layer, Option, Path, PlatformError, Ref, Result, Stdio } from "effect" import { TestConsole } from "effect/testing" import { Argument, CliOutput, Command, Flag } from "effect/unstable/cli" import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner" import * as MockTerminal from "./services/MockTerminal.ts" const ConsoleLayer = TestConsole.layer const FileSystemLayer = FileSystem.layerNoop({ exists: (path) => path.includes("/non/existent/file.txt") ? Effect.succeed(false) : Effect.succeed(true), stat: (path) => { if (path.includes("/non/existent/file.txt")) { return Effect.fail(PlatformError.badArgument({ module: "", method: "" })) } if (path.includes("workspace")) { return Effect.succeed({ type: "Directory" } as any) } return Effect.succeed({ type: "File" } as any) }, access: (path) => path.includes("/non/existent/file.txt") ? Effect.fail(PlatformError.badArgument({ module: "", method: "" })) : Effect.void }) const PathLayer = Path.layer const TerminalLayer = MockTerminal.layer const CliOutputLayer = CliOutput.layer( CliOutput.defaultFormatter({ colors: false }) ) const StdioLayer = Stdio.layerTest({}) const ChildProcessSpawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => Effect.die("Not implemented")) ) const TestLayer = Layer.mergeAll( ConsoleLayer, FileSystemLayer, PathLayer, TerminalLayer, CliOutputLayer, StdioLayer, ChildProcessSpawnerLayer ) describe("Command arguments", () => { it("should parse all argument types correctly", () => Effect.gen(function*() { // Create a Ref to store the result const resultRef = yield* Ref.make(null) // Create test command with various argument types const testCommand = Command.make("test", { name: Argument.string("name"), count: Argument.integer("count"), ratio: Argument.float("ratio"), env: Argument.choice("env", ["dev", "prod"]), config: Argument.file("config", { mustExist: false }), workspace: Argument.directory("workspace", { mustExist: false }), startDate: Argument.date("start-date"), verbose: Flag.boolean("verbose") }, (config) => Ref.set(resultRef, config)) // Test parsing with valid arguments yield* Command.runWith(testCommand, { version: "1.0.0" })([ "myapp", // name "42", // count "3.14", // ratio "dev", // env "./config.json", // config "./workspace", // workspace "2024-01-01", // startDate "--verbose" // flag ]) const result = yield* Ref.get(resultRef) assert.strictEqual(result.name, "myapp") assert.strictEqual(result.count, 42) assert.strictEqual(result.ratio, 3.14) assert.strictEqual(result.env, "dev") assert.isTrue(result.config.includes("config.json")) // assert.isTrue(result.workspace.includes("workspace")) assert.deepStrictEqual(result.startDate, new Date("2024-01-01")) assert.strictEqual(result.verbose, true) }).pipe(Effect.provide(TestLayer))) it("should handle file mustExist validation", () => Effect.gen(function*() { // Test 1: mustExist: true with existing file - should pass const result1Ref = yield* Ref.make(null) const existingFileCommand = Command.make("test", { file: Argument.file("file", { mustExist: true }) }, ({ file }) => Ref.set(result1Ref, file)) yield* Command.runWith(existingFileCommand, { version: "1.0.0" })(["/file.txt"]) const result1 = yield* Ref.get(result1Ref) assert.strictEqual(result1, "/file.txt") // Test 2: mustExist: true with non-existing file - should display error and help const runCommand = Command.runWith(existingFileCommand, { version: "1.0.0" }) yield* runCommand(["/non/existent/file.txt"]) // Check that help was shown const stdout = yield* TestConsole.logLines assert.isTrue(stdout.some((line) => String(line).includes("USAGE"))) // Check that error was shown const stderr = yield* TestConsole.errorLines assert.isTrue(stderr.some((line) => String(line).includes("ERROR"))) assert.isTrue(stderr.some((line) => String(line).includes("does not exist"))) // Test 3: mustExist: false - should always pass const result3Ref = yield* Ref.make(null) const optionalFileCommand = Command.make("test", { file: Argument.file("file", { mustExist: false }) }, ({ file }) => Ref.set(result3Ref, file)) yield* Command.runWith(optionalFileCommand, { version: "1.0.0" })([ "/non/existent/file.txt" ]) const result3 = yield* Ref.get(result3Ref) assert.isTrue(result3!.includes("/non/existent/file.txt")) }).pipe(Effect.provide(TestLayer))) it("should fail with invalid arguments", () => Effect.gen(function*() { const testCommand = Command.make("test", { count: Argument.integer("count"), env: Argument.choice("env", ["dev", "prod"]) }, (config) => Effect.succeed(config)) // Test invalid integer - should display help and error const runCommand = Command.runWith(testCommand, { version: "1.0.0" }) yield* runCommand(["not-a-number", "dev"]) // Check help was shown const stdout = yield* TestConsole.logLines const helpText = stdout.join("\n") expect(helpText).toMatchInlineSnapshot(` "USAGE test [flags] ARGUMENTS count integer env choice " `) // Check error was shown const stderr = yield* TestConsole.errorLines const errorText = stderr.join("\n") expect(errorText).toMatchInlineSnapshot(` " ERROR Invalid value for argument : "not-a-number". Expected: Failed to parse integer: Expected an integer, got NaN" `) }).pipe(Effect.provide(TestLayer))) it("should handle variadic arguments", () => Effect.gen(function*() { let result: { readonly files: ReadonlyArray } | undefined const testCommand = Command.make("test", { files: Argument.variadic(Argument.string("files")) }, (parsedConfig) => Effect.sync(() => { result = parsedConfig })) yield* Command.runWith(testCommand, { version: "1.0.0" })([ "file1.txt", "file2.txt", "file3.txt" ]) assert.isDefined(result) assert.deepStrictEqual(result.files, ["file1.txt", "file2.txt", "file3.txt"]) }).pipe(Effect.provide(TestLayer))) it("should handle choiceWithValue", () => Effect.gen(function*() { const resultRef = yield* Ref.make(null) const testCommand = Command.make("test", { level: Argument.choiceWithValue( "level", [ ["debug", 0], ["info", 1], ["error", 2] ] as const ) }, (config) => Ref.set(resultRef, config)) yield* Command.runWith(testCommand, { version: "1.0.0" })(["info"]) const result = yield* Ref.get(resultRef) assert.strictEqual(result.level, 1) }).pipe(Effect.provide(TestLayer))) it("should handle filter combinator - valid", () => Effect.gen(function*() { const resultRef = yield* Ref.make(null) const testCommand = Command.make("test", { port: Argument.integer("port").pipe( Argument.filter( (n) => n >= 1 && n <= 65535, (n) => `Port ${n} out of range (1-65535)` ) ) }, (config) => Ref.set(resultRef, config)) yield* Command.runWith(testCommand, { version: "1.0.0" })(["8080"]) const result = yield* Ref.get(resultRef) assert.strictEqual(result.port, 8080) }).pipe(Effect.provide(TestLayer))) it("should handle filter combinator - invalid", () => Effect.gen(function*() { const testCommand = Command.make("test", { port: Argument.integer("port").pipe( Argument.filter( (n) => n >= 1 && n <= 65535, (n) => `Port ${n} out of range (1-65535)` ) ) }, () => Effect.void) yield* Command.runWith(testCommand, { version: "1.0.0" })(["99999"]) const stderr = yield* TestConsole.errorLines assert.isTrue(stderr.some((line) => String(line).includes("out of range"))) }).pipe(Effect.provide(TestLayer))) it("should handle filterMap combinator - valid", () => Effect.gen(function*() { const resultRef = yield* Ref.make(null) const testCommand = Command.make("test", { positiveInt: Argument.integer("num").pipe( Argument.filterMap( (n) => n > 0 ? Option.some(n) : Option.none(), (n) => `Expected positive integer, got ${n}` ) ) }, (config) => Ref.set(resultRef, config)) yield* Command.runWith(testCommand, { version: "1.0.0" })(["42"]) const result = yield* Ref.get(resultRef) assert.strictEqual(result.positiveInt, 42) }).pipe(Effect.provide(TestLayer))) it("should handle filterMap combinator - invalid", () => Effect.gen(function*() { const testCommand = Command.make("test", { positiveInt: Argument.integer("num").pipe( Argument.filterMap( (n) => n > 0 ? Option.some(n) : Option.none(), (n) => `Expected positive integer, got ${n}` ) ) }, () => Effect.void) yield* Command.runWith(testCommand, { version: "1.0.0" })(["0"]) const stderr = yield* TestConsole.errorLines assert.isTrue(stderr.some((line) => String(line).includes("Expected positive integer"))) }).pipe(Effect.provide(TestLayer))) it("should handle orElse combinator", () => Effect.gen(function*() { const resultRef = yield* Ref.make(null) // Try parsing as integer first, fallback to 0 const testCommand = Command.make("test", { value: Argument.integer("value").pipe( Argument.orElse(() => Argument.string("value").pipe(Argument.map(() => -1))) ) }, (config) => Ref.set(resultRef, config)) // Valid integer yield* Command.runWith(testCommand, { version: "1.0.0" })(["42"]) let result = yield* Ref.get(resultRef) assert.strictEqual(result.value, 42) // Invalid integer, falls back to string path yield* Ref.set(resultRef, null) yield* Command.runWith(testCommand, { version: "1.0.0" })(["abc"]) result = yield* Ref.get(resultRef) assert.strictEqual(result.value, -1) }).pipe(Effect.provide(TestLayer))) it("should handle orElseResult combinator", () => Effect.gen(function*() { const resultRef = yield* Ref.make(null) const testCommand = Command.make("test", { value: Argument.integer("value").pipe( Argument.orElseResult(() => Argument.string("value")) ) }, (config) => Ref.set(resultRef, config)) // Valid integer - returns Success yield* Command.runWith(testCommand, { version: "1.0.0" })(["42"]) let result = yield* Ref.get(resultRef) assert.isTrue(Result.isSuccess(result.value)) assert.strictEqual(result.value.value, 42) // Invalid integer - returns Failure with string yield* Ref.set(resultRef, null) yield* Command.runWith(testCommand, { version: "1.0.0" })(["abc"]) result = yield* Ref.get(resultRef) assert.isTrue(Result.isFailure(result.value)) assert.strictEqual(result.value.value, "abc") }).pipe(Effect.provide(TestLayer))) it("should handle withMetavar combinator", () => Effect.gen(function*() { const testCommand = Command.make("test", { file: Argument.string("file").pipe( Argument.withMetavar("FILE_PATH") ) }, () => Effect.void) // Run with help flag to check pseudo name appears in help yield* Command.runWith(testCommand, { version: "1.0.0" })(["--help"]) const stdout = yield* TestConsole.logLines const helpText = stdout.join("\n") assert.isTrue(helpText.includes("FILE_PATH")) }).pipe(Effect.provide(TestLayer))) it("should handle optional arguments - when provided", () => Effect.gen(function*() { const resultRef = yield* Ref.make(null) const testCommand = Command.make("test", { label: Argument.optional( Argument.string("label").pipe( Argument.withDescription("Optional label name") ) ) }, (config) => Ref.set(resultRef, config)) // When optional argument IS provided yield* Command.runWith(testCommand, { version: "1.0.0" })(["my-label"]) const result = yield* Ref.get(resultRef) assert.isTrue(Option.isSome(result.label)) assert.strictEqual(result.label.value, "my-label") }).pipe(Effect.provide(TestLayer))) it.effect("returns none for omitted optional positional arguments", () => Effect.gen(function*() { // BUG TEST: Argument.optional() should work for positional arguments // Currently it only catches MissingOption, not MissingArgument const optionalArg = Argument.optional(Argument.string("label")) // Parse with empty arguments - should succeed with Option.none() const result = yield* optionalArg.parse({ flags: {}, arguments: [] }) // If we get here without error, optional is working const [_leftover, value] = result assert.isTrue(Option.isNone(value), "Should be Option.none()") }).pipe(Effect.provide(TestLayer))) it.effect("uses argument wording for positional InvalidValue errors", () => Effect.gen(function*() { // When a positional argument has an invalid value, the error should say "argument" // not "flag" (which would be confusing) const intArg = Argument.integer("count") const result = yield* Effect.exit( intArg.parse({ flags: {}, arguments: ["not-a-number"] }) ) assert.isTrue(result._tag === "Failure", "Should fail with invalid integer") if (result._tag === "Failure") { // Extract the error from the cause const cause = result.cause // The cause structure is { _id: 'Cause', failures: [...] } but we need to access it properly const errorMessage = String(cause) // Error message should NOT say "flag" assert.isFalse( errorMessage.includes("flag --"), `Error message should not say 'flag --' for positional arguments: ${errorMessage}` ) // Error message SHOULD say "argument" assert.isTrue( errorMessage.includes("argument"), `Error message should say 'argument': ${errorMessage}` ) } }).pipe(Effect.provide(TestLayer))) })