diff --git a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md index 8896f5d1..76945d5a 100644 --- a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md +++ b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md @@ -23,7 +23,7 @@ swiftly [--version] [--help] Install a new toolchain. ``` -swiftly install [] [--use] [--verify|no-verify] [--post-install-file=] [--progress-file=] [--assume-yes] [--verbose] [--version] [--help] +swiftly install [] [--use] [--verify|no-verify] [--post-install-file=] [--progress-file=] [--format=] [--assume-yes] [--verbose] [--version] [--help] ``` **version:** @@ -89,6 +89,11 @@ Each progress entry contains timestamp, progress percentage, and a descriptive m The file must be writable, else an error will be thrown. +**--format=\:** + +*Output format (text, json)* + + **--assume-yes:** *Disable confirmation prompts by assuming 'yes'* diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index 516fd970..11999f9a 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -81,14 +81,17 @@ struct Install: SwiftlyCommand { )) var progressFile: FilePath? + @Option(name: .long, help: "Output format (text, json)") + var format: SwiftlyCore.OutputFormat = .text + @OptionGroup var root: GlobalOptions private enum CodingKeys: String, CodingKey { - case version, use, verify, postInstallFile, root, progressFile + case version, use, verify, postInstallFile, root, progressFile, format } mutating func run() async throws { - try await self.run(Swiftly.createDefaultContext()) + try await self.run(Swiftly.createDefaultContext(format: self.format)) } private func swiftlyHomeDir(_ ctx: SwiftlyCoreContext) -> FilePath { @@ -266,7 +269,10 @@ struct Install: SwiftlyCommand { progressFile: FilePath? = nil ) async throws -> (postInstall: String?, pathChanged: Bool) { guard !config.installedToolchains.contains(version) else { - await ctx.message("\(version) is already installed.") + let installInfo = InstallInfo( + version: version, alreadyInstalled: true + ) + try await ctx.output(installInfo) return (nil, false) } @@ -312,16 +318,18 @@ struct Install: SwiftlyCommand { } } - let animation: ProgressReporterProtocol = + let animation: ProgressReporterProtocol? = if let progressFile { try JsonFileProgressReporter(ctx, filePath: progressFile) + } else if ctx.format == .json { + ConsoleProgressReporter(stream: stderrStream, header: "Downloading \(version)") } else { ConsoleProgressReporter(stream: stdoutStream, header: "Downloading \(version)") } defer { - try? animation.close() + try? animation?.close() } var lastUpdate = Date() @@ -351,7 +359,7 @@ struct Install: SwiftlyCommand { lastUpdate = Date() do { - try await animation.update( + try await animation?.update( step: progress.receivedBytes, total: progress.totalBytes!, text: @@ -368,10 +376,10 @@ struct Install: SwiftlyCommand { throw SwiftlyError( message: "\(version) does not exist at URL \(notFound.url), exiting") } catch { - try? await animation.complete(success: false) + try? await animation?.complete(success: false) throw error } - try await animation.complete(success: true) + try await animation?.complete(success: true) if verifySignature { try await Swiftly.currentPlatform.verifyToolchainSignature( @@ -427,7 +435,11 @@ struct Install: SwiftlyCommand { return (pathChanged, config) } config = newConfig - await ctx.message("\(version) installed successfully!") + let installInfo = InstallInfo( + version: version, + alreadyInstalled: false + ) + try await ctx.output(installInfo) return (postInstallScript, pathChanged) } } diff --git a/Sources/Swiftly/OutputSchema.swift b/Sources/Swiftly/OutputSchema.swift index 3664914a..6c971ed2 100644 --- a/Sources/Swiftly/OutputSchema.swift +++ b/Sources/Swiftly/OutputSchema.swift @@ -339,3 +339,28 @@ struct InstalledToolchainsListInfo: OutputData { return lines.joined(separator: "\n") } } + +struct InstallInfo: OutputData { + let version: ToolchainVersion + let alreadyInstalled: Bool + + init(version: ToolchainVersion, alreadyInstalled: Bool) { + self.version = version + self.alreadyInstalled = alreadyInstalled + } + + var description: String { + "\(self.version) is \(self.alreadyInstalled ? "already installed" : "installed successfully!")" + } + + private enum CodingKeys: String, CodingKey { + case version + case alreadyInstalled + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.version.name, forKey: .version) + try container.encode(self.alreadyInstalled, forKey: .alreadyInstalled) + } +} diff --git a/Tests/SwiftlyTests/InstallTests.swift b/Tests/SwiftlyTests/InstallTests.swift index 590d9b0a..4fd6d914 100644 --- a/Tests/SwiftlyTests/InstallTests.swift +++ b/Tests/SwiftlyTests/InstallTests.swift @@ -389,4 +389,38 @@ import Testing } } #endif + + /// Tests that `install` command with JSON format outputs correctly structured JSON. + @Test(.testHomeMockedToolchain()) func installJsonFormat() async throws { + let output = try await SwiftlyTests.runWithMockedIO( + Install.self, ["install", "5.7.0", "--post-install-file=\(fs.mktemp())", "--format", "json"], format: .json + ) + + let installInfo = try JSONDecoder().decode( + InstallInfo.self, + from: output[0].data(using: .utf8)! + ) + + #expect(installInfo.version.name == "5.7.0") + #expect(installInfo.alreadyInstalled == false) + } + + /// Tests that `install` command with JSON format correctly indicates when toolchain is already installed. + @Test(.testHomeMockedToolchain()) func installJsonFormatAlreadyInstalled() async throws { + // First install the toolchain + try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.0", "--post-install-file=\(fs.mktemp())"]) + + // Then try to install it again with JSON format + let output = try await SwiftlyTests.runWithMockedIO( + Install.self, ["install", "5.7.0", "--post-install-file=\(fs.mktemp())", "--format", "json"], format: .json + ) + + let installInfo = try JSONDecoder().decode( + InstallInfo.self, + from: output[0].data(using: .utf8)! + ) + + #expect(installInfo.version.name == "5.7.0") + #expect(installInfo.alreadyInstalled == true) + } }