diff --git a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md index 41d59179..65d975ba 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=] [--assume-yes] [--verbose] [--version] [--help] +swiftly install [] [--use] [--verify|no-verify] [--post-install-file=] [--progress-file=] [--assume-yes] [--verbose] [--version] [--help] ``` **version:** @@ -80,6 +80,14 @@ If the toolchain that is installed has extra post installation steps, they will written to this file as commands that can be run after the installation. +**--progress-file=\:** + +*A file path where progress information will be written in JSONL format* + +Progress information will be appended to this file as JSON objects, one per line. +Each progress entry contains timestamp, progress percentage, and a descriptive message. + + **--assume-yes:** *Disable confirmation prompts by assuming 'yes'* diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index 8481aada..bbf45fa8 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -71,10 +71,20 @@ struct Install: SwiftlyCommand { )) var postInstallFile: FilePath? + @Option( + help: ArgumentHelp( + "A file path where progress information will be written in JSONL format", + discussion: """ + Progress information will be appended to this file as JSON objects, one per line. + Each progress entry contains timestamp, progress percentage, and a descriptive message. + """ + )) + var progressFile: FilePath? + @OptionGroup var root: GlobalOptions private enum CodingKeys: String, CodingKey { - case version, use, verify, postInstallFile, root + case version, use, verify, postInstallFile, root, progressFile } mutating func run() async throws { @@ -93,7 +103,9 @@ struct Install: SwiftlyCommand { try await validateLinked(ctx) var config = try await Config.load(ctx) - let toolchainVersion = try await Self.determineToolchainVersion(ctx, version: self.version, config: &config) + let toolchainVersion = try await Self.determineToolchainVersion( + ctx, version: self.version, config: &config + ) let (postInstallScript, pathChanged) = try await Self.execute( ctx, @@ -102,7 +114,8 @@ struct Install: SwiftlyCommand { useInstalledToolchain: self.use, verifySignature: self.verify, verbose: self.root.verbose, - assumeYes: self.root.assumeYes + assumeYes: self.root.assumeYes, + progressFile: self.progressFile ) let shell = @@ -192,8 +205,9 @@ struct Install: SwiftlyCommand { await ctx.message("Setting up toolchain proxies...") } - let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union( - overwrite) + let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents) + .union( + overwrite) for p in proxiesToCreate { let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx) / p @@ -248,7 +262,8 @@ struct Install: SwiftlyCommand { useInstalledToolchain: Bool, verifySignature: Bool, verbose: Bool, - assumeYes: Bool + assumeYes: Bool, + progressFile: FilePath? = nil ) async throws -> (postInstall: String?, pathChanged: Bool) { guard !config.installedToolchains.contains(version) else { await ctx.message("\(version) is already installed.") @@ -258,10 +273,11 @@ struct Install: SwiftlyCommand { // Ensure the system is set up correctly before downloading it. Problems that prevent installation // will throw, while problems that prevent use of the toolchain will be written out as a post install // script for the user to run afterwards. - let postInstallScript = try await Swiftly.currentPlatform.verifySystemPrerequisitesForInstall( - ctx, platformName: config.platform.name, version: version, - requireSignatureValidation: verifySignature - ) + let postInstallScript = try await Swiftly.currentPlatform + .verifySystemPrerequisitesForInstall( + ctx, platformName: config.platform.name, version: version, + requireSignatureValidation: verifySignature + ) await ctx.message("Installing \(version)") @@ -296,10 +312,13 @@ struct Install: SwiftlyCommand { } } - let animation = PercentProgressAnimation( - stream: stdoutStream, - header: "Downloading \(version)" - ) + let animation: ProgressAnimationProtocol = + progressFile != nil + ? JsonFileProgressReporter(filePath: progressFile!) + : PercentProgressAnimation( + stream: stdoutStream, + header: "Downloading \(version)" + ) var lastUpdate = Date() @@ -315,7 +334,9 @@ struct Install: SwiftlyCommand { reportProgress: { progress in let now = Date() - guard lastUpdate.distance(to: now) > 0.25 || progress.receivedBytes == progress.totalBytes + guard + lastUpdate.distance(to: now) > 0.25 + || progress.receivedBytes == progress.totalBytes else { return } @@ -334,7 +355,8 @@ struct Install: SwiftlyCommand { } ) } catch let notFound as DownloadNotFoundError { - throw SwiftlyError(message: "\(version) does not exist at URL \(notFound.url), exiting") + throw SwiftlyError( + message: "\(version) does not exist at URL \(notFound.url), exiting") } catch { animation.complete(success: false) throw error @@ -401,7 +423,9 @@ struct Install: SwiftlyCommand { } /// Utilize the swift.org API along with the provided selector to select a toolchain for install. - public static func resolve(_ ctx: SwiftlyCoreContext, config: Config, selector: ToolchainSelector) + public static func resolve( + _ ctx: SwiftlyCoreContext, config: Config, selector: ToolchainSelector + ) async throws -> ToolchainVersion { switch selector { @@ -426,7 +450,8 @@ struct Install: SwiftlyCommand { } if let patch { - return .stable(ToolchainVersion.StableRelease(major: major, minor: minor, patch: patch)) + return .stable( + ToolchainVersion.StableRelease(major: major, minor: minor, patch: patch)) } await ctx.message("Fetching the latest stable Swift \(major).\(minor) release...") diff --git a/Sources/Swiftly/JsonFileProgressReporter.swift b/Sources/Swiftly/JsonFileProgressReporter.swift new file mode 100644 index 00000000..dd06fad8 --- /dev/null +++ b/Sources/Swiftly/JsonFileProgressReporter.swift @@ -0,0 +1,58 @@ +import Foundation +import SwiftlyCore +import SystemPackage +import TSCUtility + +enum ProgressInfo: Codable { + case step(timestamp: Date, percent: Int, text: String) + case complete(success: Bool) +} + +struct JsonFileProgressReporter: ProgressAnimationProtocol { + let filePath: FilePath + private let encoder: JSONEncoder + + init(filePath: FilePath, encoder: JSONEncoder = JSONEncoder()) { + self.filePath = filePath + self.encoder = encoder + } + + private func writeProgress(_ progress: ProgressInfo) { + let jsonData = try? self.encoder.encode(progress) + guard let jsonData = jsonData, let jsonString = String(data: jsonData, encoding: .utf8) + else { + print("Failed to encode progress entry to JSON") + return + } + + let jsonLine = jsonString + "\n" + + do { + try jsonLine.append(to: self.filePath) + } catch { + print("Failed to write progress entry to \(self.filePath): \(error)") + } + } + + func update(step: Int, total: Int, text: String) { + assert(step <= total) + self.writeProgress( + ProgressInfo.step( + timestamp: Date(), + percent: Int(Double(step) / Double(total) * 100), + text: text + )) + } + + func complete(success: Bool) { + self.writeProgress(ProgressInfo.complete(success: success)) + } + + func clear() { + do { + try FileManager.default.removeItem(atPath: self.filePath.string) + } catch { + print("Failed to clear progress file at \(self.filePath): \(error)") + } + } +} diff --git a/Sources/SwiftlyCore/FileManager+FilePath.swift b/Sources/SwiftlyCore/FileManager+FilePath.swift index 5d1453b4..af0db286 100644 --- a/Sources/SwiftlyCore/FileManager+FilePath.swift +++ b/Sources/SwiftlyCore/FileManager+FilePath.swift @@ -190,6 +190,22 @@ extension String { try self.write(to: URL(fileURLWithPath: path.string), atomically: atomically, encoding: enc) } + public func append(to path: FilePath, encoding enc: String.Encoding = .utf8) throws { + if !FileManager.default.fileExists(atPath: path.string) { + try self.write(to: path, atomically: true, encoding: enc) + return + } + + let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: path.string)) + defer { fileHandle.closeFile() } + fileHandle.seekToEndOfFile() + if let data = self.data(using: enc) { + fileHandle.write(data) + } else { + throw SwiftlyError(message: "Failed to convert string to data with encoding \(enc)") + } + } + public init(contentsOf path: FilePath, encoding enc: String.Encoding = .utf8) throws { try self.init(contentsOf: URL(fileURLWithPath: path.string), encoding: enc) } diff --git a/Tests/SwiftlyTests/InstallTests.swift b/Tests/SwiftlyTests/InstallTests.swift index 3bd0c622..3954702e 100644 --- a/Tests/SwiftlyTests/InstallTests.swift +++ b/Tests/SwiftlyTests/InstallTests.swift @@ -1,6 +1,7 @@ import Foundation @testable import Swiftly @testable import SwiftlyCore +import SystemPackage import Testing @Suite struct InstallTests { @@ -262,4 +263,37 @@ import Testing try await SwiftlyTests.installMockedToolchain(selector: ToolchainVersion.newStable.name, args: ["--use"]) try await SwiftlyTests.validateInUse(expected: .newStable) } + + /// Verify that progress information is written to the progress file when specified. + @Test(.testHomeMockedToolchain()) func installProgressFile() async throws { + let progressFile = fs.mktemp(ext: ".json") + + try await SwiftlyTests.runCommand(Install.self, [ + "install", "5.7.0", + "--post-install-file=\(fs.mktemp())", + "--progress-file=\(progressFile.string)", + ]) + + #expect(try await fs.exists(atPath: progressFile)) + + let progressContent = try String(contentsOfFile: progressFile.string) + let lines = progressContent.components(separatedBy: .newlines).filter { !$0.isEmpty } + + #expect(!lines.isEmpty, "Progress file should contain progress entries") + + // Verify that at least one progress entry exists + let hasProgressEntry = lines.contains { line in + line.contains("\"step\"") && line.contains("\"percent\"") && line.contains("\"timestamp\"") + } + #expect(hasProgressEntry, "Progress file should contain step progress entries") + + // Verify that a completion entry exists + let hasCompletionEntry = lines.contains { line in + line.contains("\"complete\"") && line.contains("\"success\"") + } + #expect(hasCompletionEntry, "Progress file should contain completion entry") + + // Clean up + try FileManager.default.removeItem(atPath: progressFile.string) + } } diff --git a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift new file mode 100644 index 00000000..3e6d3f7e --- /dev/null +++ b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift @@ -0,0 +1,133 @@ +import Foundation +@testable import Swiftly +@testable import SwiftlyCore +import SystemPackage +import Testing + +@Suite("JsonFileProgressReporter Tests") +struct JsonFileProgressReporterTests { + @Test("Test update method writes progress to file") + func testUpdateWritesProgressToFile() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.update(step: 1, total: 10, text: "Processing item 1") + + let fileContent = try String(contentsOfFile: tempFile.string) + + #expect(fileContent.contains("Processing item 1")) + #expect(fileContent.contains("\"percent\":10")) + #expect(fileContent.contains("\"step\"")) + #expect(fileContent.contains("\"timestamp\"")) + + try FileManager.default.removeItem(atPath: tempFile.string) + } + + @Test("Test complete method writes completion status") + func testCompleteWritesCompletionStatus() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.complete(success: true) + + let fileContent = try String(contentsOfFile: tempFile.string) + + #expect(fileContent.contains("\"success\":true")) + #expect(fileContent.contains("\"complete\"")) + + try FileManager.default.removeItem(atPath: tempFile.string) + } + + @Test("Test complete method writes failure status") + func testCompleteWritesFailureStatus() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.complete(success: false) + + let fileContent = try String(contentsOfFile: tempFile.string) + + #expect(fileContent.contains("\"success\":false")) + #expect(fileContent.contains("\"complete\"")) + + try FileManager.default.removeItem(atPath: tempFile.string) + } + + @Test("Test percentage calculation") + func testPercentageCalculation() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.update(step: 25, total: 100, text: "Quarter way") + + let fileContent = try String(contentsOfFile: tempFile.string) + + #expect(fileContent.contains("\"percent\":25")) + + try FileManager.default.removeItem(atPath: tempFile.string) + } + + @Test("Test clear method removes file") + func testClearRemovesFile() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.update(step: 1, total: 2, text: "Test") + + #expect(FileManager.default.fileExists(atPath: tempFile.string)) + + reporter.clear() + + #expect(!FileManager.default.fileExists(atPath: tempFile.string)) + } + + @Test("Test multiple progress updates create multiple lines") + func testMultipleUpdatesCreateMultipleLines() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.update(step: 1, total: 3, text: "Step 1") + reporter.update(step: 2, total: 3, text: "Step 2") + reporter.complete(success: true) + + let fileContent = try String(contentsOfFile: tempFile.string) + let lines = fileContent.components(separatedBy: .newlines).filter { !$0.isEmpty } + + #expect(lines.count == 3) + #expect(lines[0].contains("Step 1")) + #expect(lines[1].contains("Step 2")) + #expect(lines[2].contains("\"success\":true")) + + try FileManager.default.removeItem(atPath: tempFile.string) + } + + @Test("Test zero step edge case") + func testZeroStepEdgeCase() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.update(step: 0, total: 10, text: "Starting") + + let fileContent = try String(contentsOfFile: tempFile.string) + + #expect(fileContent.contains("\"percent\":0")) + #expect(fileContent.contains("Starting")) + + try FileManager.default.removeItem(atPath: tempFile.string) + } + + @Test("Test full completion edge case") + func testFullCompletionEdgeCase() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.update(step: 100, total: 100, text: "Done") + + let fileContent = try String(contentsOfFile: tempFile.string) + + #expect(fileContent.contains("\"percent\":100")) + #expect(fileContent.contains("Done")) + + try FileManager.default.removeItem(atPath: tempFile.string) + } +}