1import { assert, describe, it } from "@effect/vitest"
2import { Schema } from "effect"
3import { Argument, Command, Flag } from "effect/cli"
4import { fromCommand } from "effect/cli/internal/completions/descriptor"
5
6describe("CommandDescriptor", () => {
7 describe("fromCommand", () => {
8 it("extracts command name and description", () => {
9 const cmd = Command.make("deploy").pipe(
10 Command.withDescription("Deploy the application")
11 )
12 const desc = fromCommand(cmd)
13 assert.strictEqual(desc.name, "deploy")
14 assert.strictEqual(desc.description, "Deploy the application")
15 })
16
17 it("extracts string flags with aliases", () => {
18 const cmd = Command.make("test", {
19 output: Flag.String("output").pipe(
20 Flag.withAlias("o"),
21 Flag.withDescription("Output directory")
22 )
23 })
24 const desc = fromCommand(cmd)
25 assert.strictEqual(desc.flags.length, 1)
26 assert.strictEqual(desc.flags[0].name, "output")
27 assert.deepStrictEqual(desc.flags[0].aliases, ["o"])
28 assert.strictEqual(desc.flags[0].description, "Output directory")
29 assert.strictEqual(desc.flags[0].type._tag, "String")
30 })
31
32 it("extracts boolean flags", () => {
33 const cmd = Command.make("test", {
34 verbose: Flag.Boolean("verbose").pipe(Flag.withAlias("v"))
35 })
36 const desc = fromCommand(cmd)
37 assert.strictEqual(desc.flags.length, 1)
38 assert.strictEqual(desc.flags[0].name, "verbose")
39 assert.strictEqual(desc.flags[0].type._tag, "Boolean")
40 assert.deepStrictEqual(desc.flags[0].aliases, ["v"])
41 })
42
43 it("extracts integer and float flags", () => {
44 const cmd = Command.make("test", {
45 port: Flag.Int("port"),
46 ratio: Flag.Finite("ratio")
47 })
48 const desc = fromCommand(cmd)
49 assert.strictEqual(desc.flags.length, 2)
50 const portFlag = desc.flags.find((f) => f.name === "port")!
51 const ratioFlag = desc.flags.find((f) => f.name === "ratio")!
52 assert.strictEqual(portFlag.type._tag, "Int")
53 assert.strictEqual(ratioFlag.type._tag, "Finite")
54 })
55
56 it("extracts file, directory, and path flags", () => {
57 const cmd = Command.make("test", {
58 input: Flag.File("input"),
59 outDir: Flag.Directory("out-dir"),
60 config: Flag.Path("config", { pathType: "either" })
61 })
62 const desc = fromCommand(cmd)
63 assert.strictEqual(desc.flags.length, 3)
64 const inputFlag = desc.flags.find((f) => f.name === "input")!
65 const outDirFlag = desc.flags.find((f) => f.name === "out-dir")!
66 const configFlag = desc.flags.find((f) => f.name === "config")!
67 assert.deepStrictEqual(inputFlag.type, { _tag: "Path", pathType: "file" })
68 assert.deepStrictEqual(outDirFlag.type, { _tag: "Path", pathType: "directory" })
69 assert.deepStrictEqual(configFlag.type, { _tag: "Path", pathType: "either" })
70 })
71
72 it("retains path semantics when a path has a custom metavar", () => {
73 const command = Command.make("app", {
74 directory: Flag.Path("directory", { pathType: "directory", typeName: "DIR" }),
75 file: Argument.Path("file", { pathType: "file" }).pipe(Argument.withMetavar("INPUT"))
76 })
77 const descriptor = fromCommand(command)
78
79 assert.deepStrictEqual(descriptor.flags[0].type, { _tag: "Path", pathType: "directory" })
80 assert.deepStrictEqual(descriptor.arguments[0].type, { _tag: "Path", pathType: "file" })
81 })
82
83 it("classifies file-backed flags and arguments as file paths", () => {
84 const command = Command.make("app", {
85 flagText: Flag.FileText("flag-text"),
86 flagParse: Flag.FileParse("flag-parse"),
87 flagSchema: Flag.FileSchema("flag-schema", Schema.Unknown),
88 argumentText: Argument.FileText("argument-text"),
89 argumentParse: Argument.FileParse("argument-parse"),
90 argumentSchema: Argument.FileSchema("argument-schema", Schema.Unknown)
91 })
92 const descriptor = fromCommand(command)
93 const fileType = { _tag: "Path", pathType: "file" } as const
94
95 assert.deepStrictEqual(descriptor.flags.map((flag) => flag.type), [fileType, fileType, fileType])
96 assert.deepStrictEqual(descriptor.arguments.map((argument) => argument.type), [fileType, fileType, fileType])
97 })
98
99 it("extracts choice flags with values", () => {
100 const cmd = Command.make("test", {
101 color: Flag.Literals("color", ["red", "green", "blue"])
102 })
103 const desc = fromCommand(cmd)
104 assert.strictEqual(desc.flags.length, 1)
105 assert.deepStrictEqual(desc.flags[0].type, {
106 _tag: "Choice",
107 values: ["red", "green", "blue"]
108 })
109 })
110
111 it("extracts optional flags", () => {
112 const cmd = Command.make("test", {
113 name: Flag.String("name").pipe(Flag.optional)
114 })
115 const desc = fromCommand(cmd)
116 assert.strictEqual(desc.flags.length, 1)
117 assert.strictEqual(desc.flags[0].name, "name")
118 assert.strictEqual(desc.flags[0].type._tag, "String")
119 })
120
121 it("extracts positional arguments with types", () => {
122 const cmd = Command.make("test", {
123 name: Argument.String("name"),
124 count: Argument.Int("count")
125 })
126 const desc = fromCommand(cmd)
127 assert.strictEqual(desc.arguments.length, 2)
128 const nameArg = desc.arguments.find((a) => a.name === "name")!
129 const countArg = desc.arguments.find((a) => a.name === "count")!
130 assert.strictEqual(nameArg.type._tag, "String")
131 assert.strictEqual(countArg.type._tag, "Int")
132 assert.strictEqual(nameArg.required, true)
133 assert.strictEqual(countArg.required, true)
134 })
135
136 it("extracts variadic arguments", () => {
137 const cmd = Command.make("test", {
138 files: Argument.String("files").pipe(Argument.variadic({ min: 1 }))
139 })
140 const desc = fromCommand(cmd)
141 assert.strictEqual(desc.arguments.length, 1)
142 assert.strictEqual(desc.arguments[0].name, "files")
143 assert.strictEqual(desc.arguments[0].variadic, true)
144 assert.strictEqual(desc.arguments[0].required, true)
145 })
146
147 it("extracts optional arguments", () => {
148 const cmd = Command.make("test", {
149 email: Argument.String("email").pipe(Argument.optional)
150 })
151 const desc = fromCommand(cmd)
152 assert.strictEqual(desc.arguments.length, 1)
153 assert.strictEqual(desc.arguments[0].name, "email")
154 assert.strictEqual(desc.arguments[0].required, false)
155 })
156
157 it("extracts nested subcommands recursively", () => {
158 const leaf = Command.make("leaf").pipe(
159 Command.withDescription("A leaf command")
160 )
161 const mid = Command.make("mid").pipe(
162 Command.withSubcommands([leaf])
163 )
164 const root = Command.make("root").pipe(
165 Command.withSubcommands([mid])
166 )
167 const desc = fromCommand(root)
168 assert.strictEqual(desc.subcommands.length, 1)
169 assert.strictEqual(desc.subcommands[0].name, "mid")
170 assert.strictEqual(desc.subcommands[0].subcommands.length, 1)
171 assert.strictEqual(desc.subcommands[0].subcommands[0].name, "leaf")
172 assert.strictEqual(desc.subcommands[0].subcommands[0].description, "A leaf command")
173 })
174
175 it("extracts descriptions from flags and arguments", () => {
176 const cmd = Command.make("test", {
177 port: Flag.Int("port").pipe(
178 Flag.withDescription("Port number")
179 ),
180 host: Argument.String("host").pipe(
181 Argument.withDescription("Hostname")
182 )
183 })
184 const desc = fromCommand(cmd)
185 assert.strictEqual(desc.flags[0].description, "Port number")
186 assert.strictEqual(desc.arguments[0].description, "Hostname")
187 })
188
189 it("handles commands with no flags or arguments", () => {
190 const cmd = Command.make("noop")
191 const desc = fromCommand(cmd)
192 assert.strictEqual(desc.name, "noop")
193 assert.strictEqual(desc.flags.length, 0)
194 assert.strictEqual(desc.arguments.length, 0)
195 assert.strictEqual(desc.subcommands.length, 0)
196 })
197
198 it("handles deeply nested command trees", () => {
199 const level3 = Command.make("level3", {
200 flag: Flag.Boolean("deep")
201 })
202 const level2 = Command.make("level2").pipe(
203 Command.withSubcommands([level3])
204 )
205 const level1 = Command.make("level1").pipe(
206 Command.withSubcommands([level2])
207 )
208 const root = Command.make("root").pipe(
209 Command.withSubcommands([level1])
210 )
211 const desc = fromCommand(root)
212 const deepCmd = desc.subcommands[0].subcommands[0].subcommands[0]
213 assert.strictEqual(deepCmd.name, "level3")
214 assert.strictEqual(deepCmd.flags.length, 1)
215 assert.strictEqual(deepCmd.flags[0].name, "deep")
216 assert.strictEqual(deepCmd.flags[0].type._tag, "Boolean")
217 })
218
219 it("extracts choice arguments", () => {
220 const cmd = Command.make("test", {
221 env: Argument.Literals("env", ["dev", "staging", "prod"])
222 })
223 const desc = fromCommand(cmd)
224 assert.strictEqual(desc.arguments.length, 1)
225 assert.deepStrictEqual(desc.arguments[0].type, {
226 _tag: "Choice",
227 values: ["dev", "staging", "prod"]
228 })
229 })
230
231 it("extracts file/directory arguments", () => {
232 const cmd = Command.make("test", {
233 input: Argument.File("input", { mustExist: false }),
234 outDir: Argument.Directory("output", { mustExist: false })
235 })
236 const desc = fromCommand(cmd)
237 assert.strictEqual(desc.arguments.length, 2)
238 const inputArg = desc.arguments.find((a) => a.name === "input")!
239 const outputArg = desc.arguments.find((a) => a.name === "output")!
240 assert.deepStrictEqual(inputArg.type, { _tag: "Path", pathType: "file" })
241 assert.deepStrictEqual(outputArg.type, { _tag: "Path", pathType: "directory" })
242 })
243
244 it("extracts multiple subcommands", () => {
245 const a = Command.make("alpha").pipe(Command.withDescription("First"))
246 const b = Command.make("beta").pipe(Command.withDescription("Second"))
247 const c = Command.make("gamma").pipe(Command.withDescription("Third"))
248 const root = Command.make("root").pipe(
249 Command.withSubcommands([a, b, c])
250 )
251 const desc = fromCommand(root)
252 assert.strictEqual(desc.subcommands.length, 3)
253 assert.strictEqual(desc.subcommands[0].name, "alpha")
254 assert.strictEqual(desc.subcommands[1].name, "beta")
255 assert.strictEqual(desc.subcommands[2].name, "gamma")
256 })
257
258 it("prefers short descriptions for subcommand listings", () => {
259 const build = Command.make("build").pipe(
260 Command.withDescription("Build the project and all artifacts"),
261 Command.withShortDescription("Build artifacts")
262 )
263 const test = Command.make("test").pipe(
264 Command.withDescription("Run the full test suite")
265 )
266 const root = Command.make("root").pipe(Command.withSubcommands([build, test]))
267
268 const desc = fromCommand(root)
269 assert.strictEqual(desc.subcommands[0].description, "Build artifacts")
270 assert.strictEqual(desc.subcommands[1].description, "Run the full test suite")
271 })
272 })
273})