Skip to content

Commit bce44d7

Browse files
authored
Json Progress File (#391)
* Json Progress File * Improve JSON progress file handling and error reporting * Refactor progress file parsing and improve test validation
1 parent 06d29fa commit bce44d7

File tree

6 files changed

+394
-20
lines changed

6 files changed

+394
-20
lines changed

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ swiftly [--version] [--help]
2323
Install a new toolchain.
2424

2525
```
26-
swiftly install [<version>] [--use] [--verify|no-verify] [--post-install-file=<post-install-file>] [--assume-yes] [--verbose] [--version] [--help]
26+
swiftly install [<version>] [--use] [--verify|no-verify] [--post-install-file=<post-install-file>] [--progress-file=<progress-file>] [--assume-yes] [--verbose] [--version] [--help]
2727
```
2828

2929
**version:**
@@ -80,6 +80,15 @@ If the toolchain that is installed has extra post installation steps, they will
8080
written to this file as commands that can be run after the installation.
8181

8282

83+
**--progress-file=\<progress-file\>:**
84+
85+
*A file path where progress information will be written in JSONL format*
86+
87+
Progress information will be appended to this file as JSON objects, one per line.
88+
Each progress entry contains timestamp, progress percentage, and a descriptive message.
89+
The file must be writable, else an error will be thrown.
90+
91+
8392
**--assume-yes:**
8493

8594
*Disable confirmation prompts by assuming 'yes'*

Sources/Swiftly/Install.swift

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,21 @@ struct Install: SwiftlyCommand {
7171
))
7272
var postInstallFile: FilePath?
7373

74+
@Option(
75+
help: ArgumentHelp(
76+
"A file path where progress information will be written in JSONL format",
77+
discussion: """
78+
Progress information will be appended to this file as JSON objects, one per line.
79+
Each progress entry contains timestamp, progress percentage, and a descriptive message.
80+
The file must be writable, else an error will be thrown.
81+
"""
82+
))
83+
var progressFile: FilePath?
84+
7485
@OptionGroup var root: GlobalOptions
7586

7687
private enum CodingKeys: String, CodingKey {
77-
case version, use, verify, postInstallFile, root
88+
case version, use, verify, postInstallFile, root, progressFile
7889
}
7990

8091
mutating func run() async throws {
@@ -93,7 +104,9 @@ struct Install: SwiftlyCommand {
93104
try await validateLinked(ctx)
94105

95106
var config = try await Config.load(ctx)
96-
let toolchainVersion = try await Self.determineToolchainVersion(ctx, version: self.version, config: &config)
107+
let toolchainVersion = try await Self.determineToolchainVersion(
108+
ctx, version: self.version, config: &config
109+
)
97110

98111
let (postInstallScript, pathChanged) = try await Self.execute(
99112
ctx,
@@ -102,7 +115,8 @@ struct Install: SwiftlyCommand {
102115
useInstalledToolchain: self.use,
103116
verifySignature: self.verify,
104117
verbose: self.root.verbose,
105-
assumeYes: self.root.assumeYes
118+
assumeYes: self.root.assumeYes,
119+
progressFile: self.progressFile
106120
)
107121

108122
let shell =
@@ -192,8 +206,9 @@ struct Install: SwiftlyCommand {
192206
await ctx.message("Setting up toolchain proxies...")
193207
}
194208

195-
let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union(
196-
overwrite)
209+
let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents)
210+
.union(
211+
overwrite)
197212

198213
for p in proxiesToCreate {
199214
let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx) / p
@@ -248,7 +263,8 @@ struct Install: SwiftlyCommand {
248263
useInstalledToolchain: Bool,
249264
verifySignature: Bool,
250265
verbose: Bool,
251-
assumeYes: Bool
266+
assumeYes: Bool,
267+
progressFile: FilePath? = nil
252268
) async throws -> (postInstall: String?, pathChanged: Bool) {
253269
guard !config.installedToolchains.contains(version) else {
254270
await ctx.message("\(version) is already installed.")
@@ -258,10 +274,11 @@ struct Install: SwiftlyCommand {
258274
// Ensure the system is set up correctly before downloading it. Problems that prevent installation
259275
// will throw, while problems that prevent use of the toolchain will be written out as a post install
260276
// script for the user to run afterwards.
261-
let postInstallScript = try await Swiftly.currentPlatform.verifySystemPrerequisitesForInstall(
262-
ctx, platformName: config.platform.name, version: version,
263-
requireSignatureValidation: verifySignature
264-
)
277+
let postInstallScript = try await Swiftly.currentPlatform
278+
.verifySystemPrerequisitesForInstall(
279+
ctx, platformName: config.platform.name, version: version,
280+
requireSignatureValidation: verifySignature
281+
)
265282

266283
await ctx.message("Installing \(version)")
267284

@@ -296,10 +313,17 @@ struct Install: SwiftlyCommand {
296313
}
297314
}
298315

299-
let animation = PercentProgressAnimation(
300-
stream: stdoutStream,
301-
header: "Downloading \(version)"
302-
)
316+
let animation: ProgressAnimationProtocol =
317+
if let progressFile
318+
{
319+
try JsonFileProgressReporter(ctx, filePath: progressFile)
320+
} else {
321+
PercentProgressAnimation(stream: stdoutStream, header: "Downloading \(version)")
322+
}
323+
324+
defer {
325+
try? (animation as? JsonFileProgressReporter)?.close()
326+
}
303327

304328
var lastUpdate = Date()
305329

@@ -315,7 +339,9 @@ struct Install: SwiftlyCommand {
315339
reportProgress: { progress in
316340
let now = Date()
317341

318-
guard lastUpdate.distance(to: now) > 0.25 || progress.receivedBytes == progress.totalBytes
342+
guard
343+
lastUpdate.distance(to: now) > 0.25
344+
|| progress.receivedBytes == progress.totalBytes
319345
else {
320346
return
321347
}
@@ -334,7 +360,8 @@ struct Install: SwiftlyCommand {
334360
}
335361
)
336362
} catch let notFound as DownloadNotFoundError {
337-
throw SwiftlyError(message: "\(version) does not exist at URL \(notFound.url), exiting")
363+
throw SwiftlyError(
364+
message: "\(version) does not exist at URL \(notFound.url), exiting")
338365
} catch {
339366
animation.complete(success: false)
340367
throw error
@@ -401,7 +428,9 @@ struct Install: SwiftlyCommand {
401428
}
402429

403430
/// Utilize the swift.org API along with the provided selector to select a toolchain for install.
404-
public static func resolve(_ ctx: SwiftlyCoreContext, config: Config, selector: ToolchainSelector)
431+
public static func resolve(
432+
_ ctx: SwiftlyCoreContext, config: Config, selector: ToolchainSelector
433+
)
405434
async throws -> ToolchainVersion
406435
{
407436
switch selector {
@@ -426,7 +455,8 @@ struct Install: SwiftlyCommand {
426455
}
427456

428457
if let patch {
429-
return .stable(ToolchainVersion.StableRelease(major: major, minor: minor, patch: patch))
458+
return .stable(
459+
ToolchainVersion.StableRelease(major: major, minor: minor, patch: patch))
430460
}
431461

432462
await ctx.message("Fetching the latest stable Swift \(major).\(minor) release...")
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import Foundation
2+
import SwiftlyCore
3+
import SystemPackage
4+
import TSCUtility
5+
6+
enum ProgressInfo: Codable {
7+
case step(timestamp: Date, percent: Int, text: String)
8+
case complete(success: Bool)
9+
}
10+
11+
struct JsonFileProgressReporter: ProgressAnimationProtocol {
12+
let filePath: FilePath
13+
private let encoder: JSONEncoder
14+
private let ctx: SwiftlyCoreContext
15+
private let fileHandle: FileHandle
16+
17+
init(_ ctx: SwiftlyCoreContext, filePath: FilePath, encoder: JSONEncoder = JSONEncoder()) throws
18+
{
19+
self.ctx = ctx
20+
self.filePath = filePath
21+
self.encoder = encoder
22+
self.fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: filePath.string))
23+
}
24+
25+
private func writeProgress(_ progress: ProgressInfo) {
26+
let jsonData = try? self.encoder.encode(progress)
27+
guard let jsonData = jsonData else {
28+
Task { [ctx = self.ctx] in
29+
await ctx.message("Failed to encode progress entry to JSON")
30+
}
31+
return
32+
}
33+
34+
self.fileHandle.write(jsonData)
35+
self.fileHandle.write("\n".data(using: .utf8) ?? Data())
36+
try? self.fileHandle.synchronize()
37+
}
38+
39+
func update(step: Int, total: Int, text: String) {
40+
guard total > 0 && step <= total else {
41+
return
42+
}
43+
self.writeProgress(
44+
ProgressInfo.step(
45+
timestamp: Date(),
46+
percent: Int(Double(step) / Double(total) * 100),
47+
text: text
48+
))
49+
}
50+
51+
func complete(success: Bool) {
52+
self.writeProgress(ProgressInfo.complete(success: success))
53+
}
54+
55+
func clear() {
56+
// not implemented for JSON file reporter
57+
}
58+
59+
func close() throws {
60+
try self.fileHandle.close()
61+
}
62+
}

Sources/SwiftlyCore/FileManager+FilePath.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public enum FileSystem {
6060
case mode(Int)
6161
}
6262

63-
public static func create(_ options: CreateOptions..., file: FilePath, contents: Data?) async throws {
63+
public static func create(_ options: CreateOptions..., file: FilePath, contents: Data? = nil) async throws {
6464
try await Self.create(options, file: file, contents: contents)
6565
}
6666

Tests/SwiftlyTests/InstallTests.swift

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Foundation
22
@testable import Swiftly
33
@testable import SwiftlyCore
4+
import SystemPackage
45
import Testing
56

67
@Suite struct InstallTests {
@@ -262,4 +263,130 @@ import Testing
262263
try await SwiftlyTests.installMockedToolchain(selector: ToolchainVersion.newStable.name, args: ["--use"])
263264
try await SwiftlyTests.validateInUse(expected: .newStable)
264265
}
266+
267+
/// Verify that progress information is written to the progress file when specified.
268+
@Test(.testHomeMockedToolchain()) func installProgressFile() async throws {
269+
let progressFile = fs.mktemp(ext: ".json")
270+
try await fs.create(.mode(0o644), file: progressFile)
271+
272+
try await SwiftlyTests.runCommand(Install.self, [
273+
"install", "5.7.0",
274+
"--post-install-file=\(fs.mktemp())",
275+
"--progress-file=\(progressFile.string)",
276+
])
277+
278+
#expect(try await fs.exists(atPath: progressFile))
279+
280+
let decoder = JSONDecoder()
281+
let progressContent = try String(contentsOfFile: progressFile.string)
282+
let progressInfo = try progressContent.split(separator: "\n")
283+
.filter { !$0.isEmpty }
284+
.map { line in
285+
try decoder.decode(ProgressInfo.self, from: Data(line.utf8))
286+
}
287+
288+
#expect(!progressInfo.isEmpty, "Progress file should contain progress entries")
289+
290+
// Verify that at least one step progress entry exists
291+
let hasStepEntry = progressInfo.contains { info in
292+
if case .step = info { return true }
293+
return false
294+
}
295+
#expect(hasStepEntry, "Progress file should contain step progress entries")
296+
297+
// Verify that a completion entry exists
298+
let hasCompletionEntry = progressInfo.contains { info in
299+
if case .complete = info { return true }
300+
return false
301+
}
302+
#expect(hasCompletionEntry, "Progress file should contain completion entry")
303+
304+
// Clean up
305+
try FileManager.default.removeItem(atPath: progressFile.string)
306+
}
307+
308+
#if os(Linux) || os(macOS)
309+
@Test(.testHomeMockedToolchain())
310+
func installProgressFileNamedPipe() async throws {
311+
let tempDir = NSTemporaryDirectory()
312+
let pipePath = tempDir + "swiftly_install_progress_pipe"
313+
314+
let result = mkfifo(pipePath, 0o644)
315+
guard result == 0 else {
316+
return // Skip test if mkfifo syscall failed
317+
}
318+
319+
defer {
320+
try? FileManager.default.removeItem(atPath: pipePath)
321+
}
322+
323+
var receivedMessages: [ProgressInfo] = []
324+
let decoder = JSONDecoder()
325+
var installCompleted = false
326+
327+
let readerTask = Task {
328+
guard let fileHandle = FileHandle(forReadingAtPath: pipePath) else { return }
329+
defer { fileHandle.closeFile() }
330+
331+
var buffer = Data()
332+
333+
while !installCompleted {
334+
let data = fileHandle.availableData
335+
if data.isEmpty {
336+
try await Task.sleep(nanoseconds: 100_000_000)
337+
continue
338+
}
339+
340+
buffer.append(data)
341+
342+
while let newlineRange = buffer.range(of: "\n".data(using: .utf8)!) {
343+
let lineData = buffer.subdata(in: 0..<newlineRange.lowerBound)
344+
buffer.removeSubrange(0..<newlineRange.upperBound)
345+
346+
if !lineData.isEmpty {
347+
if let progress = try? decoder.decode(ProgressInfo.self, from: lineData) {
348+
receivedMessages.append(progress)
349+
if case .complete = progress {
350+
installCompleted = true
351+
return
352+
}
353+
}
354+
}
355+
}
356+
}
357+
}
358+
359+
let installTask = Task {
360+
try await SwiftlyTests.runCommand(Install.self, [
361+
"install", "5.7.0",
362+
"--post-install-file=\(fs.mktemp())",
363+
"--progress-file=\(pipePath)",
364+
])
365+
}
366+
367+
await withTaskGroup(of: Void.self) { group in
368+
group.addTask { try? await readerTask.value }
369+
group.addTask { try? await installTask.value }
370+
}
371+
372+
#expect(!receivedMessages.isEmpty, "Named pipe should receive progress entries")
373+
374+
let hasCompletionEntry = receivedMessages.contains { info in
375+
if case .complete = info { return true }
376+
return false
377+
}
378+
#expect(hasCompletionEntry, "Named pipe should receive completion entry")
379+
380+
for message in receivedMessages {
381+
switch message {
382+
case let .step(timestamp, percent, text):
383+
#expect(timestamp.timeIntervalSince1970 > 0)
384+
#expect(percent >= 0 && percent <= 100)
385+
#expect(!text.isEmpty)
386+
case let .complete(success):
387+
#expect(success == true)
388+
}
389+
}
390+
}
391+
#endif
265392
}

0 commit comments

Comments
 (0)