Skip to content

Commit 3d2574e

Browse files
committed
Json Progress File
1 parent 06d29fa commit 3d2574e

File tree

6 files changed

+293
-19
lines changed

6 files changed

+293
-19
lines changed

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

Lines changed: 9 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,14 @@ 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+
90+
8391
**--assume-yes:**
8492

8593
*Disable confirmation prompts by assuming 'yes'*

Sources/Swiftly/Install.swift

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,20 @@ 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+
"""
81+
))
82+
var progressFile: FilePath?
83+
7484
@OptionGroup var root: GlobalOptions
7585

7686
private enum CodingKeys: String, CodingKey {
77-
case version, use, verify, postInstallFile, root
87+
case version, use, verify, postInstallFile, root, progressFile
7888
}
7989

8090
mutating func run() async throws {
@@ -93,7 +103,9 @@ struct Install: SwiftlyCommand {
93103
try await validateLinked(ctx)
94104

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

98110
let (postInstallScript, pathChanged) = try await Self.execute(
99111
ctx,
@@ -102,7 +114,8 @@ struct Install: SwiftlyCommand {
102114
useInstalledToolchain: self.use,
103115
verifySignature: self.verify,
104116
verbose: self.root.verbose,
105-
assumeYes: self.root.assumeYes
117+
assumeYes: self.root.assumeYes,
118+
progressFile: self.progressFile
106119
)
107120

108121
let shell =
@@ -192,8 +205,9 @@ struct Install: SwiftlyCommand {
192205
await ctx.message("Setting up toolchain proxies...")
193206
}
194207

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

198212
for p in proxiesToCreate {
199213
let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx) / p
@@ -248,7 +262,8 @@ struct Install: SwiftlyCommand {
248262
useInstalledToolchain: Bool,
249263
verifySignature: Bool,
250264
verbose: Bool,
251-
assumeYes: Bool
265+
assumeYes: Bool,
266+
progressFile: FilePath? = nil
252267
) async throws -> (postInstall: String?, pathChanged: Bool) {
253268
guard !config.installedToolchains.contains(version) else {
254269
await ctx.message("\(version) is already installed.")
@@ -258,10 +273,11 @@ struct Install: SwiftlyCommand {
258273
// Ensure the system is set up correctly before downloading it. Problems that prevent installation
259274
// will throw, while problems that prevent use of the toolchain will be written out as a post install
260275
// 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-
)
276+
let postInstallScript = try await Swiftly.currentPlatform
277+
.verifySystemPrerequisitesForInstall(
278+
ctx, platformName: config.platform.name, version: version,
279+
requireSignatureValidation: verifySignature
280+
)
265281

266282
await ctx.message("Installing \(version)")
267283

@@ -296,10 +312,13 @@ struct Install: SwiftlyCommand {
296312
}
297313
}
298314

299-
let animation = PercentProgressAnimation(
300-
stream: stdoutStream,
301-
header: "Downloading \(version)"
302-
)
315+
let animation: ProgressAnimationProtocol =
316+
progressFile != nil
317+
? JsonFileProgressReporter(filePath: progressFile!)
318+
: PercentProgressAnimation(
319+
stream: stdoutStream,
320+
header: "Downloading \(version)"
321+
)
303322

304323
var lastUpdate = Date()
305324

@@ -315,7 +334,9 @@ struct Install: SwiftlyCommand {
315334
reportProgress: { progress in
316335
let now = Date()
317336

318-
guard lastUpdate.distance(to: now) > 0.25 || progress.receivedBytes == progress.totalBytes
337+
guard
338+
lastUpdate.distance(to: now) > 0.25
339+
|| progress.receivedBytes == progress.totalBytes
319340
else {
320341
return
321342
}
@@ -334,7 +355,8 @@ struct Install: SwiftlyCommand {
334355
}
335356
)
336357
} catch let notFound as DownloadNotFoundError {
337-
throw SwiftlyError(message: "\(version) does not exist at URL \(notFound.url), exiting")
358+
throw SwiftlyError(
359+
message: "\(version) does not exist at URL \(notFound.url), exiting")
338360
} catch {
339361
animation.complete(success: false)
340362
throw error
@@ -401,7 +423,9 @@ struct Install: SwiftlyCommand {
401423
}
402424

403425
/// 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)
426+
public static func resolve(
427+
_ ctx: SwiftlyCoreContext, config: Config, selector: ToolchainSelector
428+
)
405429
async throws -> ToolchainVersion
406430
{
407431
switch selector {
@@ -426,7 +450,8 @@ struct Install: SwiftlyCommand {
426450
}
427451

428452
if let patch {
429-
return .stable(ToolchainVersion.StableRelease(major: major, minor: minor, patch: patch))
453+
return .stable(
454+
ToolchainVersion.StableRelease(major: major, minor: minor, patch: patch))
430455
}
431456

432457
await ctx.message("Fetching the latest stable Swift \(major).\(minor) release...")
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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+
15+
init(filePath: FilePath, encoder: JSONEncoder = JSONEncoder()) {
16+
self.filePath = filePath
17+
self.encoder = encoder
18+
}
19+
20+
private func writeProgress(_ progress: ProgressInfo) {
21+
let jsonData = try? self.encoder.encode(progress)
22+
guard let jsonData = jsonData, let jsonString = String(data: jsonData, encoding: .utf8)
23+
else {
24+
print("Failed to encode progress entry to JSON")
25+
return
26+
}
27+
28+
let jsonLine = jsonString + "\n"
29+
30+
do {
31+
try jsonLine.append(to: self.filePath)
32+
} catch {
33+
print("Failed to write progress entry to \(self.filePath): \(error)")
34+
}
35+
}
36+
37+
func update(step: Int, total: Int, text: String) {
38+
assert(step <= total)
39+
self.writeProgress(
40+
ProgressInfo.step(
41+
timestamp: Date(),
42+
percent: Int(Double(step) / Double(total) * 100),
43+
text: text
44+
))
45+
}
46+
47+
func complete(success: Bool) {
48+
self.writeProgress(ProgressInfo.complete(success: success))
49+
}
50+
51+
func clear() {
52+
do {
53+
try FileManager.default.removeItem(atPath: self.filePath.string)
54+
} catch {
55+
print("Failed to clear progress file at \(self.filePath): \(error)")
56+
}
57+
}
58+
}

Sources/SwiftlyCore/FileManager+FilePath.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,22 @@ extension String {
190190
try self.write(to: URL(fileURLWithPath: path.string), atomically: atomically, encoding: enc)
191191
}
192192

193+
public func append(to path: FilePath, encoding enc: String.Encoding = .utf8) throws {
194+
if !FileManager.default.fileExists(atPath: path.string) {
195+
try self.write(to: path, atomically: true, encoding: enc)
196+
return
197+
}
198+
199+
let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: path.string))
200+
defer { fileHandle.closeFile() }
201+
fileHandle.seekToEndOfFile()
202+
if let data = self.data(using: enc) {
203+
fileHandle.write(data)
204+
} else {
205+
throw SwiftlyError(message: "Failed to convert string to data with encoding \(enc)")
206+
}
207+
}
208+
193209
public init(contentsOf path: FilePath, encoding enc: String.Encoding = .utf8) throws {
194210
try self.init(contentsOf: URL(fileURLWithPath: path.string), encoding: enc)
195211
}

Tests/SwiftlyTests/InstallTests.swift

Lines changed: 34 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,37 @@ 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+
271+
try await SwiftlyTests.runCommand(Install.self, [
272+
"install", "5.7.0",
273+
"--post-install-file=\(fs.mktemp())",
274+
"--progress-file=\(progressFile.string)",
275+
])
276+
277+
#expect(try await fs.exists(atPath: progressFile))
278+
279+
let progressContent = try String(contentsOfFile: progressFile.string)
280+
let lines = progressContent.components(separatedBy: .newlines).filter { !$0.isEmpty }
281+
282+
#expect(!lines.isEmpty, "Progress file should contain progress entries")
283+
284+
// Verify that at least one progress entry exists
285+
let hasProgressEntry = lines.contains { line in
286+
line.contains("\"step\"") && line.contains("\"percent\"") && line.contains("\"timestamp\"")
287+
}
288+
#expect(hasProgressEntry, "Progress file should contain step progress entries")
289+
290+
// Verify that a completion entry exists
291+
let hasCompletionEntry = lines.contains { line in
292+
line.contains("\"complete\"") && line.contains("\"success\"")
293+
}
294+
#expect(hasCompletionEntry, "Progress file should contain completion entry")
295+
296+
// Clean up
297+
try FileManager.default.removeItem(atPath: progressFile.string)
298+
}
265299
}

0 commit comments

Comments
 (0)