Skip to content

Refactor list command to support json format #388

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ Finally, all installed toolchains can be uninstalled by specifying 'all':
List installed toolchains.

```
swiftly list [<toolchain-selector>] [--version] [--help]
swiftly list [<toolchain-selector>] [--format=<format>] [--version] [--help]
```

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


**--format=\<format\>:**

*Output format (text, json)*


**--version:**

*Show the version.*
Expand Down
61 changes: 12 additions & 49 deletions Sources/Swiftly/List.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ArgumentParser
import Foundation
import SwiftlyCore

struct List: SwiftlyCommand {
Expand Down Expand Up @@ -33,8 +34,11 @@ struct List: SwiftlyCommand {
))
var toolchainSelector: String?

@Option(name: .long, help: "Output format (text, json)")
var format: SwiftlyCore.OutputFormat = .text

mutating func run() async throws {
try await self.run(Swiftly.createDefaultContext())
try await self.run(Swiftly.createDefaultContext(format: self.format))
}

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

let printToolchain = { (toolchain: ToolchainVersion) in
var message = "\(toolchain)"
if let inUse, toolchain == inUse {
message += " (in use)"
}
if toolchain == config.inUse {
message += " (default)"
}
await ctx.message(message)
let installedToolchainInfos = toolchains.compactMap { toolchain -> InstallToolchainInfo? in
InstallToolchainInfo(
version: toolchain,
inUse: inUse == toolchain,
isDefault: toolchain == config.inUse
)
}

if let selector {
let modifier = switch selector {
case let .stable(major, minor, nil):
if let minor {
"Swift \(major).\(minor) release"
} else {
"Swift \(major) release"
}
case .snapshot(.main, nil):
"main development snapshot"
case let .snapshot(.release(major, minor), nil):
"\(major).\(minor) development snapshot"
default:
"matching"
}

let message = "Installed \(modifier) toolchains"
await ctx.message(message)
await ctx.message(String(repeating: "-", count: message.count))
for toolchain in toolchains {
await printToolchain(toolchain)
}
} else {
await ctx.message("Installed release toolchains")
await ctx.message("----------------------------")
for toolchain in toolchains {
guard toolchain.isStableRelease() else {
continue
}
await printToolchain(toolchain)
}

await ctx.message("")
await ctx.message("Installed snapshot toolchains")
await ctx.message("-----------------------------")
for toolchain in toolchains where toolchain.isSnapshot() {
await printToolchain(toolchain)
}
}
try await ctx.output(InstalledToolchainsListInfo(toolchains: installedToolchainInfos, selector: selector))
}
}
131 changes: 121 additions & 10 deletions Sources/Swiftly/OutputSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ enum ToolchainSource: Codable, CustomStringConvertible {
}
}

private enum ToolchainVersionCodingKeys: String, CodingKey {
case name
case type
case branch
case major
case minor
case patch
case date
}

struct AvailableToolchainInfo: OutputData {
let version: ToolchainVersion
let inUse: Bool
Expand Down Expand Up @@ -86,16 +96,6 @@ struct AvailableToolchainInfo: OutputData {
case installed
}

private enum ToolchainVersionCodingKeys: String, CodingKey {
case name
case type
case branch
case major
case minor
case patch
case date
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.inUse, forKey: .inUse)
Expand Down Expand Up @@ -175,3 +175,114 @@ struct AvailableToolchainsListInfo: OutputData {
return lines.joined(separator: "\n")
}
}

struct InstallToolchainInfo: OutputData {
let version: ToolchainVersion
let inUse: Bool
let isDefault: Bool

var description: String {
var message = "\(version)"

if self.inUse {
message += " (in use)"
}
if self.isDefault {
message += " (default)"
}
return message
}

private enum CodingKeys: String, CodingKey {
case version
case inUse
case `default`
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.inUse, forKey: .inUse)
try container.encode(self.isDefault, forKey: .default)

// Encode the version as a object
var versionContainer = container.nestedContainer(
keyedBy: ToolchainVersionCodingKeys.self, forKey: .version
)
try versionContainer.encode(self.version.name, forKey: .name)

switch self.version {
case let .stable(release):
try versionContainer.encode("stable", forKey: .type)
try versionContainer.encode(release.major, forKey: .major)
try versionContainer.encode(release.minor, forKey: .minor)
try versionContainer.encode(release.patch, forKey: .patch)
case let .snapshot(snapshot):
try versionContainer.encode("snapshot", forKey: .type)
try versionContainer.encode(snapshot.date, forKey: .date)
try versionContainer.encode(snapshot.branch.name, forKey: .branch)

if let major = snapshot.branch.major,
let minor = snapshot.branch.minor
{
try versionContainer.encode(major, forKey: .major)
try versionContainer.encode(minor, forKey: .minor)
}
}
}
}

struct InstalledToolchainsListInfo: OutputData {
let toolchains: [InstallToolchainInfo]
let selector: ToolchainSelector?

init(toolchains: [InstallToolchainInfo], selector: ToolchainSelector? = nil) {
self.toolchains = toolchains
self.selector = selector
}

private enum CodingKeys: String, CodingKey {
case toolchains
}

var description: String {
var lines: [String] = []

if let selector = selector {
let modifier =
switch selector
{
case let .stable(major, minor, nil):
if let minor {
"Swift \(major).\(minor) release"
} else {
"Swift \(major) release"
}
case .snapshot(.main, nil):
"main development snapshot"
case let .snapshot(.release(major, minor), nil):
"\(major).\(minor) development snapshot"
default:
"matching"
}

let header = "Installed \(modifier) toolchains"
lines.append(header)
lines.append(String(repeating: "-", count: header.count))
lines.append(contentsOf: self.toolchains.map(\.description))
} else {
let releaseToolchains = self.toolchains.filter { $0.version.isStableRelease() }
let snapshotToolchains = self.toolchains.filter { $0.version.isSnapshot() }

lines.append("Installed release toolchains")
lines.append("----------------------------")
lines.append(contentsOf: releaseToolchains.map(\.description))

lines.append("")
lines.append("Installed snapshot toolchains")
lines.append("-----------------------------")
lines.append(contentsOf: snapshotToolchains.map(\.description))
}

return lines.joined(separator: "\n")
}
}
98 changes: 95 additions & 3 deletions Tests/SwiftlyTests/ListTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,16 @@ import Testing
}

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

let parsedToolchains = output.compactMap { outputLine in
let parsedToolchains = lines.compactMap { outputLine in
Set<ToolchainVersion>.allToolchains().first {
outputLine.contains(String(describing: $0))
}
}

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

Expand Down Expand Up @@ -127,8 +128,9 @@ import Testing
}

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

let inUse = output.filter { $0.contains("in use") }
let inUse = lines.filter { $0.contains("in use") && $0.contains(toolchain.name) }
#expect(inUse == ["\(toolchain) (in use) (default)"])
}

Expand Down Expand Up @@ -173,4 +175,94 @@ import Testing
#expect(toolchains == [])
}
}

/// Tests that running `list` command with JSON format outputs correctly structured JSON.
@Test func listJsonFormat() async throws {
try await self.runListTest {
let output = try await SwiftlyTests.runWithMockedIO(
List.self, ["list", "--format", "json"], format: .json
)

let result = try JSONSerialization.jsonObject(
Copy link
Member

@cmcgee1024 cmcgee1024 Jun 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Instead of decoding this JSON data into a dictionary, why not decode into a InstalledToolchainsListInfo instead? The checks below would just be simple field accesses instead of dictionary lookups.

Copy link
Contributor Author

@roulpriya roulpriya Jun 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing that forces us to implement Decodable in all the structs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be much effort to make them Decodable?

with: output[0].data(using: .utf8)!, options: []
) as! [String: Any]

#expect(result["toolchains"] != nil)
let toolchains = result["toolchains"] as! [[String: Any]]
#expect(toolchains.count == Set<ToolchainVersion>.allToolchains().count)

for toolchain in toolchains {
#expect(toolchain["version"] != nil)
#expect(toolchain["inUse"] != nil)
#expect(toolchain["default"] != nil)

let version = toolchain["version"] as! [String: Any]
#expect(version["name"] != nil)
#expect(version["type"] != nil)
}
}
}

/// Tests that running `list` command with JSON format and selector outputs filtered results.
@Test func listJsonFormatWithSelector() async throws {
try await self.runListTest {
var output = try await SwiftlyTests.runWithMockedIO(
List.self, ["list", "5", "--format", "json"], format: .json
)

var result = try JSONSerialization.jsonObject(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: same as above. Why not decode into a InstalledToolchainsListInfo here?

with: output[0].data(using: .utf8)!, options: []
) as! [String: Any]

var toolchains = result["toolchains"] as! [[String: Any]]
#expect(toolchains.count == Self.sortedReleaseToolchains.count)

for toolchain in toolchains {
let version = toolchain["version"] as! [String: Any]
#expect(version["type"] as! String == "stable")
}

output = try await SwiftlyTests.runWithMockedIO(
List.self, ["list", "main-snapshot", "--format", "json"], format: .json
)

result = try JSONSerialization.jsonObject(
with: output[0].data(using: .utf8)!, options: []
) as! [String: Any]

toolchains = result["toolchains"] as! [[String: Any]]
#expect(toolchains.count == 2)

for toolchain in toolchains {
let version = toolchain["version"] as! [String: Any]
#expect(version["type"] as! String == "snapshot")
#expect(version["branch"] as! String == "main")
}
}
}

/// Tests that the JSON output correctly indicates which toolchain is in use.
@Test func listJsonFormatInUse() async throws {
try await self.runListTest {
try await SwiftlyTests.runCommand(Use.self, ["use", ToolchainVersion.newStable.name])

let output = try await SwiftlyTests.runWithMockedIO(
List.self, ["list", "--format", "json"], format: .json
)

let result = try JSONSerialization.jsonObject(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: see above

with: output[0].data(using: .utf8)!, options: []
) as! [String: Any]

let toolchains = result["toolchains"] as! [[String: Any]]

let inUseToolchains = toolchains.filter { $0["inUse"] as! Bool }
#expect(inUseToolchains.count == 1)

let inUseToolchain = inUseToolchains[0]
let version = inUseToolchain["version"] as! [String: Any]
#expect(version["name"] as! String == ToolchainVersion.newStable.name)
#expect(inUseToolchain["default"] as! Bool == true)
}
}
}