Skip to content

Commit ae57fbc

Browse files
committed
Refactor list command to support json format
1 parent 4fa8b10 commit ae57fbc

File tree

4 files changed

+234
-63
lines changed

4 files changed

+234
-63
lines changed

Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ Finally, all installed toolchains can be uninstalled by specifying 'all':
295295
List installed toolchains.
296296

297297
```
298-
swiftly list [<toolchain-selector>] [--version] [--help]
298+
swiftly list [<toolchain-selector>] [--format=<format>] [--version] [--help]
299299
```
300300

301301
**toolchain-selector:**
@@ -321,6 +321,11 @@ The installed snapshots for a given development branch can be listed by specifyi
321321
$ swiftly list 5.7-snapshot
322322

323323

324+
**--format=\<format\>:**
325+
326+
*Output format (text, json)*
327+
328+
324329
**--version:**
325330

326331
*Show the version.*

Sources/Swiftly/List.swift

Lines changed: 12 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import ArgumentParser
2+
import Foundation
23
import SwiftlyCore
34

45
struct List: SwiftlyCommand {
@@ -33,8 +34,11 @@ struct List: SwiftlyCommand {
3334
))
3435
var toolchainSelector: String?
3536

37+
@Option(name: .long, help: "Output format (text, json)")
38+
var format: SwiftlyCore.OutputFormat = .text
39+
3640
mutating func run() async throws {
37-
try await self.run(Swiftly.createDefaultContext())
41+
try await self.run(Swiftly.createDefaultContext(format: self.format))
3842
}
3943

4044
mutating func run(_ ctx: SwiftlyCoreContext) async throws {
@@ -51,55 +55,14 @@ struct List: SwiftlyCommand {
5155
let toolchains = config.listInstalledToolchains(selector: selector).sorted { $0 > $1 }
5256
let (inUse, _) = try await selectToolchain(ctx, config: &config)
5357

54-
let printToolchain = { (toolchain: ToolchainVersion) in
55-
var message = "\(toolchain)"
56-
if let inUse, toolchain == inUse {
57-
message += " (in use)"
58-
}
59-
if toolchain == config.inUse {
60-
message += " (default)"
61-
}
62-
await ctx.message(message)
58+
let installedToolchainInfos = toolchains.compactMap { toolchain -> InstallToolchainInfo? in
59+
InstallToolchainInfo(
60+
version: toolchain,
61+
inUse: inUse == toolchain,
62+
isDefault: toolchain == config.inUse
63+
)
6364
}
6465

65-
if let selector {
66-
let modifier = switch selector {
67-
case let .stable(major, minor, nil):
68-
if let minor {
69-
"Swift \(major).\(minor) release"
70-
} else {
71-
"Swift \(major) release"
72-
}
73-
case .snapshot(.main, nil):
74-
"main development snapshot"
75-
case let .snapshot(.release(major, minor), nil):
76-
"\(major).\(minor) development snapshot"
77-
default:
78-
"matching"
79-
}
80-
81-
let message = "Installed \(modifier) toolchains"
82-
await ctx.message(message)
83-
await ctx.message(String(repeating: "-", count: message.count))
84-
for toolchain in toolchains {
85-
await printToolchain(toolchain)
86-
}
87-
} else {
88-
await ctx.message("Installed release toolchains")
89-
await ctx.message("----------------------------")
90-
for toolchain in toolchains {
91-
guard toolchain.isStableRelease() else {
92-
continue
93-
}
94-
await printToolchain(toolchain)
95-
}
96-
97-
await ctx.message("")
98-
await ctx.message("Installed snapshot toolchains")
99-
await ctx.message("-----------------------------")
100-
for toolchain in toolchains where toolchain.isSnapshot() {
101-
await printToolchain(toolchain)
102-
}
103-
}
66+
try await ctx.output(InstalledToolchainsListInfo(toolchains: installedToolchainInfos, selector: selector))
10467
}
10568
}

Sources/Swiftly/OutputSchema.swift

Lines changed: 121 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ enum ToolchainSource: Codable, CustomStringConvertible {
5959
}
6060
}
6161

62+
private enum ToolchainVersionCodingKeys: String, CodingKey {
63+
case name
64+
case type
65+
case branch
66+
case major
67+
case minor
68+
case patch
69+
case date
70+
}
71+
6272
struct AvailableToolchainInfo: OutputData {
6373
let version: ToolchainVersion
6474
let inUse: Bool
@@ -86,16 +96,6 @@ struct AvailableToolchainInfo: OutputData {
8696
case installed
8797
}
8898

89-
private enum ToolchainVersionCodingKeys: String, CodingKey {
90-
case name
91-
case type
92-
case branch
93-
case major
94-
case minor
95-
case patch
96-
case date
97-
}
98-
9999
public func encode(to encoder: Encoder) throws {
100100
var container = encoder.container(keyedBy: CodingKeys.self)
101101
try container.encode(self.inUse, forKey: .inUse)
@@ -175,3 +175,114 @@ struct AvailableToolchainsListInfo: OutputData {
175175
return lines.joined(separator: "\n")
176176
}
177177
}
178+
179+
struct InstallToolchainInfo: OutputData {
180+
let version: ToolchainVersion
181+
let inUse: Bool
182+
let isDefault: Bool
183+
184+
var description: String {
185+
var message = "\(version)"
186+
187+
if self.inUse {
188+
message += " (in use)"
189+
}
190+
if self.isDefault {
191+
message += " (default)"
192+
}
193+
return message
194+
}
195+
196+
private enum CodingKeys: String, CodingKey {
197+
case version
198+
case inUse
199+
case `default`
200+
}
201+
202+
public func encode(to encoder: Encoder) throws {
203+
var container = encoder.container(keyedBy: CodingKeys.self)
204+
try container.encode(self.inUse, forKey: .inUse)
205+
try container.encode(self.isDefault, forKey: .default)
206+
207+
// Encode the version as a object
208+
var versionContainer = container.nestedContainer(
209+
keyedBy: ToolchainVersionCodingKeys.self, forKey: .version
210+
)
211+
try versionContainer.encode(self.version.name, forKey: .name)
212+
213+
switch self.version {
214+
case let .stable(release):
215+
try versionContainer.encode("stable", forKey: .type)
216+
try versionContainer.encode(release.major, forKey: .major)
217+
try versionContainer.encode(release.minor, forKey: .minor)
218+
try versionContainer.encode(release.patch, forKey: .patch)
219+
case let .snapshot(snapshot):
220+
try versionContainer.encode("snapshot", forKey: .type)
221+
try versionContainer.encode(snapshot.date, forKey: .date)
222+
try versionContainer.encode(snapshot.branch.name, forKey: .branch)
223+
224+
if let major = snapshot.branch.major,
225+
let minor = snapshot.branch.minor
226+
{
227+
try versionContainer.encode(major, forKey: .major)
228+
try versionContainer.encode(minor, forKey: .minor)
229+
}
230+
}
231+
}
232+
}
233+
234+
struct InstalledToolchainsListInfo: OutputData {
235+
let toolchains: [InstallToolchainInfo]
236+
let selector: ToolchainSelector?
237+
238+
init(toolchains: [InstallToolchainInfo], selector: ToolchainSelector? = nil) {
239+
self.toolchains = toolchains
240+
self.selector = selector
241+
}
242+
243+
private enum CodingKeys: String, CodingKey {
244+
case toolchains
245+
}
246+
247+
var description: String {
248+
var lines: [String] = []
249+
250+
if let selector = selector {
251+
let modifier =
252+
switch selector
253+
{
254+
case let .stable(major, minor, nil):
255+
if let minor {
256+
"Swift \(major).\(minor) release"
257+
} else {
258+
"Swift \(major) release"
259+
}
260+
case .snapshot(.main, nil):
261+
"main development snapshot"
262+
case let .snapshot(.release(major, minor), nil):
263+
"\(major).\(minor) development snapshot"
264+
default:
265+
"matching"
266+
}
267+
268+
let header = "Installed \(modifier) toolchains"
269+
lines.append(header)
270+
lines.append(String(repeating: "-", count: header.count))
271+
lines.append(contentsOf: self.toolchains.map(\.description))
272+
} else {
273+
let releaseToolchains = self.toolchains.filter { $0.version.isStableRelease() }
274+
let snapshotToolchains = self.toolchains.filter { $0.version.isSnapshot() }
275+
276+
lines.append("Installed release toolchains")
277+
lines.append("----------------------------")
278+
lines.append(contentsOf: releaseToolchains.map(\.description))
279+
280+
lines.append("")
281+
lines.append("Installed snapshot toolchains")
282+
lines.append("-----------------------------")
283+
lines.append(contentsOf: snapshotToolchains.map(\.description))
284+
}
285+
286+
return lines.joined(separator: "\n")
287+
}
288+
}

Tests/SwiftlyTests/ListTests.swift

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,16 @@ import Testing
4646
}
4747

4848
let output = try await SwiftlyTests.runWithMockedIO(List.self, args)
49+
let lines = output.flatMap { $0.split(separator: "\n").map(String.init) }
4950

50-
let parsedToolchains = output.compactMap { outputLine in
51+
let parsedToolchains = lines.compactMap { outputLine in
5152
Set<ToolchainVersion>.allToolchains().first {
5253
outputLine.contains(String(describing: $0))
5354
}
5455
}
5556

5657
// Ensure extra toolchains weren't accidentally included in the output.
57-
guard parsedToolchains.count == output.filter({ $0.hasPrefix("Swift") || $0.contains("-snapshot") }).count else {
58+
guard parsedToolchains.count == lines.filter({ $0.hasPrefix("Swift") || $0.contains("-snapshot") }).count else {
5859
throw SwiftlyTestError(message: "unexpected listed toolchains in \(output)")
5960
}
6061

@@ -127,8 +128,9 @@ import Testing
127128
}
128129

129130
let output = try await SwiftlyTests.runWithMockedIO(List.self, listArgs)
131+
let lines = output.flatMap { $0.split(separator: "\n").map(String.init) }
130132

131-
let inUse = output.filter { $0.contains("in use") }
133+
let inUse = lines.filter { $0.contains("in use") && $0.contains(toolchain.name) }
132134
#expect(inUse == ["\(toolchain) (in use) (default)"])
133135
}
134136

@@ -173,4 +175,94 @@ import Testing
173175
#expect(toolchains == [])
174176
}
175177
}
178+
179+
/// Tests that running `list` command with JSON format outputs correctly structured JSON.
180+
@Test func listJsonFormat() async throws {
181+
try await self.runListTest {
182+
let output = try await SwiftlyTests.runWithMockedIO(
183+
List.self, ["list", "--format", "json"], format: .json
184+
)
185+
186+
let result = try JSONSerialization.jsonObject(
187+
with: output[0].data(using: .utf8)!, options: []
188+
) as! [String: Any]
189+
190+
#expect(result["toolchains"] != nil)
191+
let toolchains = result["toolchains"] as! [[String: Any]]
192+
#expect(toolchains.count == Set<ToolchainVersion>.allToolchains().count)
193+
194+
for toolchain in toolchains {
195+
#expect(toolchain["version"] != nil)
196+
#expect(toolchain["inUse"] != nil)
197+
#expect(toolchain["default"] != nil)
198+
199+
let version = toolchain["version"] as! [String: Any]
200+
#expect(version["name"] != nil)
201+
#expect(version["type"] != nil)
202+
}
203+
}
204+
}
205+
206+
/// Tests that running `list` command with JSON format and selector outputs filtered results.
207+
@Test func listJsonFormatWithSelector() async throws {
208+
try await self.runListTest {
209+
var output = try await SwiftlyTests.runWithMockedIO(
210+
List.self, ["list", "5", "--format", "json"], format: .json
211+
)
212+
213+
var result = try JSONSerialization.jsonObject(
214+
with: output[0].data(using: .utf8)!, options: []
215+
) as! [String: Any]
216+
217+
var toolchains = result["toolchains"] as! [[String: Any]]
218+
#expect(toolchains.count == Self.sortedReleaseToolchains.count)
219+
220+
for toolchain in toolchains {
221+
let version = toolchain["version"] as! [String: Any]
222+
#expect(version["type"] as! String == "stable")
223+
}
224+
225+
output = try await SwiftlyTests.runWithMockedIO(
226+
List.self, ["list", "main-snapshot", "--format", "json"], format: .json
227+
)
228+
229+
result = try JSONSerialization.jsonObject(
230+
with: output[0].data(using: .utf8)!, options: []
231+
) as! [String: Any]
232+
233+
toolchains = result["toolchains"] as! [[String: Any]]
234+
#expect(toolchains.count == 2)
235+
236+
for toolchain in toolchains {
237+
let version = toolchain["version"] as! [String: Any]
238+
#expect(version["type"] as! String == "snapshot")
239+
#expect(version["branch"] as! String == "main")
240+
}
241+
}
242+
}
243+
244+
/// Tests that the JSON output correctly indicates which toolchain is in use.
245+
@Test func listJsonFormatInUse() async throws {
246+
try await self.runListTest {
247+
try await SwiftlyTests.runCommand(Use.self, ["use", ToolchainVersion.newStable.name])
248+
249+
let output = try await SwiftlyTests.runWithMockedIO(
250+
List.self, ["list", "--format", "json"], format: .json
251+
)
252+
253+
let result = try JSONSerialization.jsonObject(
254+
with: output[0].data(using: .utf8)!, options: []
255+
) as! [String: Any]
256+
257+
let toolchains = result["toolchains"] as! [[String: Any]]
258+
259+
let inUseToolchains = toolchains.filter { $0["inUse"] as! Bool }
260+
#expect(inUseToolchains.count == 1)
261+
262+
let inUseToolchain = inUseToolchains[0]
263+
let version = inUseToolchain["version"] as! [String: Any]
264+
#expect(version["name"] as! String == ToolchainVersion.newStable.name)
265+
#expect(inUseToolchain["default"] as! Bool == true)
266+
}
267+
}
176268
}

0 commit comments

Comments
 (0)