From c7790217eabd41313daa2770ba60c6024d858300 Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Fri, 20 Jun 2025 11:59:46 +0530 Subject: [PATCH] Refactor list command to support json format --- .../SwiftlyDocs.docc/swiftly-cli-reference.md | 7 +- Sources/Swiftly/List.swift | 61 ++---- Sources/Swiftly/OutputSchema.swift | 190 ++++++++++++++++-- Sources/SwiftlyCore/OutputFormatter.swift | 2 +- Tests/SwiftlyTests/ListTests.swift | 91 ++++++++- Tests/SwiftlyTests/UseTests.swift | 23 ++- 6 files changed, 296 insertions(+), 78 deletions(-) diff --git a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md index 3071f8a0..41d59179 100644 --- a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md +++ b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md @@ -295,7 +295,7 @@ Finally, all installed toolchains can be uninstalled by specifying 'all': List installed toolchains. ``` -swiftly list [] [--version] [--help] +swiftly list [] [--format=] [--version] [--help] ``` **toolchain-selector:** @@ -321,6 +321,11 @@ The installed snapshots for a given development branch can be listed by specifyi $ swiftly list 5.7-snapshot +**--format=\:** + +*Output format (text, json)* + + **--version:** *Show the version.* diff --git a/Sources/Swiftly/List.swift b/Sources/Swiftly/List.swift index 978add03..7efcb9c8 100644 --- a/Sources/Swiftly/List.swift +++ b/Sources/Swiftly/List.swift @@ -1,4 +1,5 @@ import ArgumentParser +import Foundation import SwiftlyCore struct List: SwiftlyCommand { @@ -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 { @@ -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)) } } diff --git a/Sources/Swiftly/OutputSchema.swift b/Sources/Swiftly/OutputSchema.swift index 8302865a..3664914a 100644 --- a/Sources/Swiftly/OutputSchema.swift +++ b/Sources/Swiftly/OutputSchema.swift @@ -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 @@ -82,24 +92,14 @@ struct AvailableToolchainInfo: OutputData { private enum CodingKeys: String, CodingKey { case version case inUse - case `default` + case isDefault 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) - try container.encode(self.isDefault, forKey: .default) + try container.encode(self.isDefault, forKey: .isDefault) try container.encode(self.installed, forKey: .installed) // Encode the version as a object @@ -131,7 +131,7 @@ struct AvailableToolchainInfo: OutputData { struct AvailableToolchainsListInfo: OutputData { let toolchains: [AvailableToolchainInfo] - let selector: ToolchainSelector? + var selector: ToolchainSelector? init(toolchains: [AvailableToolchainInfo], selector: ToolchainSelector? = nil) { self.toolchains = toolchains @@ -175,3 +175,167 @@ struct AvailableToolchainsListInfo: OutputData { return lines.joined(separator: "\n") } } + +struct InstallToolchainInfo: OutputData { + let version: ToolchainVersion + let inUse: Bool + let isDefault: Bool + + init(version: ToolchainVersion, inUse: Bool, isDefault: Bool) { + self.version = version + self.inUse = inUse + self.isDefault = isDefault + } + + 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 isDefault + } + + 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: .isDefault) + + // 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) + } + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.inUse = try container.decode(Bool.self, forKey: .inUse) + self.isDefault = try container.decode(Bool.self, forKey: .isDefault) + + // Decode the version as a object + let versionContainer = try container.nestedContainer( + keyedBy: ToolchainVersionCodingKeys.self, forKey: .version + ) + let name = try versionContainer.decode(String.self, forKey: .name) + + switch try versionContainer.decode(String.self, forKey: .type) { + case "stable": + let major = try versionContainer.decode(Int.self, forKey: .major) + let minor = try versionContainer.decode(Int.self, forKey: .minor) + let patch = try versionContainer.decode(Int.self, forKey: .patch) + self.version = .stable( + ToolchainVersion.StableRelease(major: major, minor: minor, patch: patch)) + case "snapshot": + let date = try versionContainer.decode(String.self, forKey: .date) + let branchName = try versionContainer.decode(String.self, forKey: .branch) + let branchMajor = try? versionContainer.decodeIfPresent(Int.self, forKey: .major) + let branchMinor = try? versionContainer.decodeIfPresent(Int.self, forKey: .minor) + + // Determine the branch from the decoded data + let branch: ToolchainVersion.Snapshot.Branch + if branchName == "main" { + branch = .main + } else if let major = branchMajor, let minor = branchMinor { + branch = .release(major: major, minor: minor) + } else { + throw DecodingError.dataCorruptedError( + forKey: ToolchainVersionCodingKeys.branch, + in: versionContainer, + debugDescription: "Invalid branch format: \(branchName)" + ) + } + + self.version = .snapshot( + ToolchainVersion.Snapshot( + branch: branch, + date: date + )) + default: + throw DecodingError.dataCorruptedError( + forKey: ToolchainVersionCodingKeys.type, + in: versionContainer, + debugDescription: "Unknown toolchain type" + ) + } + } +} + +struct InstalledToolchainsListInfo: OutputData { + let toolchains: [InstallToolchainInfo] + var selector: ToolchainSelector? + + 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") + } +} diff --git a/Sources/SwiftlyCore/OutputFormatter.swift b/Sources/SwiftlyCore/OutputFormatter.swift index 94a103bd..178ded4f 100644 --- a/Sources/SwiftlyCore/OutputFormatter.swift +++ b/Sources/SwiftlyCore/OutputFormatter.swift @@ -14,7 +14,7 @@ public protocol OutputFormatter { func format(_ data: OutputData) throws -> String } -public protocol OutputData: Encodable, CustomStringConvertible { +public protocol OutputData: CustomStringConvertible, Codable { var description: String { get } } diff --git a/Tests/SwiftlyTests/ListTests.swift b/Tests/SwiftlyTests/ListTests.swift index 5489bb21..e9bc6bee 100644 --- a/Tests/SwiftlyTests/ListTests.swift +++ b/Tests/SwiftlyTests/ListTests.swift @@ -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.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)") } @@ -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)"]) } @@ -173,4 +175,87 @@ 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 listInfo = try JSONDecoder().decode( + InstalledToolchainsListInfo.self, + from: output[0].data(using: .utf8)! + ) + + #expect(listInfo.toolchains.count == Set.allToolchains().count) + + for toolchain in listInfo.toolchains { + #expect(toolchain.version.name.isEmpty == false) + #expect(toolchain.inUse != nil) + #expect(toolchain.isDefault != 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 listInfo = try JSONDecoder().decode( + InstalledToolchainsListInfo.self, + from: output[0].data(using: .utf8)! + ) + + #expect(listInfo.toolchains.count == Self.sortedReleaseToolchains.count) + + for toolchain in listInfo.toolchains { + #expect(toolchain.version.isStableRelease()) + } + + output = try await SwiftlyTests.runWithMockedIO( + List.self, ["list", "main-snapshot", "--format", "json"], format: .json + ) + + listInfo = try JSONDecoder().decode( + InstalledToolchainsListInfo.self, + from: output[0].data(using: .utf8)! + ) + + #expect(listInfo.toolchains.count == 2) + + for toolchain in listInfo.toolchains { + #expect(toolchain.version.isSnapshot()) + if let snapshot = toolchain.version.asSnapshot { + #expect(snapshot.branch == .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 listInfo = try JSONDecoder().decode( + InstalledToolchainsListInfo.self, + from: output[0].data(using: .utf8)! + ) + + let inUseToolchains = listInfo.toolchains.filter(\.inUse) + #expect(inUseToolchains.count == 1) + + let inUseToolchain = inUseToolchains[0] + #expect(inUseToolchain.version.name == ToolchainVersion.newStable.name) + #expect(inUseToolchain.isDefault == true) + } + } } diff --git a/Tests/SwiftlyTests/UseTests.swift b/Tests/SwiftlyTests/UseTests.swift index 4a38c810..d7c5e71b 100644 --- a/Tests/SwiftlyTests/UseTests.swift +++ b/Tests/SwiftlyTests/UseTests.swift @@ -335,22 +335,23 @@ import Testing var output = try await SwiftlyTests.runWithMockedIO( Use.self, ["use", "-g", "--format", "json", toolchain.name], format: .json ) - // Decode the output to a dictionary, to avoid making everything Decodable. - let result = - try JSONSerialization.jsonObject( - with: output[0].data(using: .utf8)!, options: [] - ) as! [String: Any] - #expect(result["version"] as! String == toolchain.name) + + let toolchainSetInfo = try JSONDecoder().decode( + ToolchainSetInfo.self, + from: output[0].data(using: .utf8)! + ) + #expect(toolchainSetInfo.version.name == toolchain.name) output = try await SwiftlyTests.runWithMockedIO( Use.self, ["use", "-g", "--print-location", "--format", "json"], format: .json ) - let result2 = - try JSONSerialization.jsonObject( - with: output[0].data(using: .utf8)!, options: [] - ) as! [String: Any] + + let locationInfo = try JSONDecoder().decode( + LocationInfo.self, + from: output[0].data(using: .utf8)! + ) #expect( - result2["path"] as! String + locationInfo.path == Swiftly.currentPlatform.findToolchainLocation( SwiftlyTests.ctx, toolchain ).string)