Skip to content

Commit 6a07745

Browse files
committed
Improve JSON progress file handling and error reporting
1 parent 3d2574e commit 6a07745

File tree

5 files changed

+153
-122
lines changed

5 files changed

+153
-122
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ written to this file as commands that can be run after the installation.
8686

8787
Progress information will be appended to this file as JSON objects, one per line.
8888
Each progress entry contains timestamp, progress percentage, and a descriptive message.
89+
The file must be writable, else an error will be thrown.
8990

9091

9192
**--assume-yes:**

Sources/Swiftly/Install.swift

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ struct Install: SwiftlyCommand {
7777
discussion: """
7878
Progress information will be appended to this file as JSON objects, one per line.
7979
Each progress entry contains timestamp, progress percentage, and a descriptive message.
80+
The file must be writable, else an error will be thrown.
8081
"""
8182
))
8283
var progressFile: FilePath?
@@ -313,12 +314,16 @@ struct Install: SwiftlyCommand {
313314
}
314315

315316
let animation: ProgressAnimationProtocol =
316-
progressFile != nil
317-
? JsonFileProgressReporter(filePath: progressFile!)
318-
: PercentProgressAnimation(
319-
stream: stdoutStream,
320-
header: "Downloading \(version)"
321-
)
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+
}
322327

323328
var lastUpdate = Date()
324329

Sources/Swiftly/JsonFileProgressReporter.swift

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,30 @@ enum ProgressInfo: Codable {
1111
struct JsonFileProgressReporter: ProgressAnimationProtocol {
1212
let filePath: FilePath
1313
private let encoder: JSONEncoder
14+
private let ctx: SwiftlyCoreContext
15+
private let fileHandle: FileHandle
1416

15-
init(filePath: FilePath, encoder: JSONEncoder = JSONEncoder()) {
17+
init(_ ctx: SwiftlyCoreContext, filePath: FilePath, encoder: JSONEncoder = JSONEncoder()) throws
18+
{
19+
self.ctx = ctx
1620
self.filePath = filePath
1721
self.encoder = encoder
22+
self.fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: filePath.string))
1823
}
1924

2025
private func writeProgress(_ progress: ProgressInfo) {
2126
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")
27+
guard let jsonData = jsonData else {
28+
Task { [ctx = self.ctx] in
29+
await ctx.message("Failed to encode progress entry to JSON")
30+
}
2531
return
2632
}
2733

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-
}
34+
self.fileHandle.seekToEndOfFile()
35+
self.fileHandle.write(jsonData)
36+
self.fileHandle.write("\n".data(using: .utf8) ?? Data())
37+
self.fileHandle.synchronizeFile()
3538
}
3639

3740
func update(step: Int, total: Int, text: String) {
@@ -49,10 +52,11 @@ struct JsonFileProgressReporter: ProgressAnimationProtocol {
4952
}
5053

5154
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-
}
55+
self.fileHandle.truncateFile(atOffset: 0)
56+
self.fileHandle.synchronizeFile()
57+
}
58+
59+
func close() throws {
60+
try self.fileHandle.close()
5761
}
5862
}

Sources/SwiftlyCore/FileManager+FilePath.swift

Lines changed: 1 addition & 10 deletions
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

@@ -195,15 +195,6 @@ extension String {
195195
try self.write(to: path, atomically: true, encoding: enc)
196196
return
197197
}
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-
}
207198
}
208199

209200
public init(contentsOf path: FilePath, encoding enc: String.Encoding = .utf8) throws {
Lines changed: 120 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,133 +1,163 @@
11
import Foundation
2-
@testable import Swiftly
3-
@testable import SwiftlyCore
42
import SystemPackage
53
import Testing
64

7-
@Suite("JsonFileProgressReporter Tests")
8-
struct JsonFileProgressReporterTests {
9-
@Test("Test update method writes progress to file")
10-
func testUpdateWritesProgressToFile() throws {
5+
@testable import Swiftly
6+
@testable import SwiftlyCore
7+
8+
@Suite struct JsonFileProgressReporterTests {
9+
@Test("Test update method writes progress to file as valid JSONNL")
10+
func testUpdateWritesProgressToFile() async throws {
1111
let tempFile = fs.mktemp(ext: ".json")
12-
let reporter = JsonFileProgressReporter(filePath: tempFile)
12+
try await fs.create(.mode(Int(0o644)), file: tempFile)
13+
defer { try? FileManager.default.removeItem(atPath: tempFile.string) }
14+
let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile)
1315

1416
reporter.update(step: 1, total: 10, text: "Processing item 1")
15-
16-
let fileContent = try String(contentsOfFile: tempFile.string)
17-
18-
#expect(fileContent.contains("Processing item 1"))
19-
#expect(fileContent.contains("\"percent\":10"))
20-
#expect(fileContent.contains("\"step\""))
21-
#expect(fileContent.contains("\"timestamp\""))
22-
23-
try FileManager.default.removeItem(atPath: tempFile.string)
17+
try reporter.close()
18+
19+
let decoder = JSONDecoder()
20+
21+
let info = try String(contentsOfFile: tempFile.string).split(separator: "\n")
22+
.filter {
23+
!$0.isEmpty
24+
}.map {
25+
try decoder.decode(
26+
ProgressInfo.self,
27+
from: Data($0.trimmingCharacters(in: .whitespacesAndNewlines).utf8)
28+
)
29+
}
30+
31+
#expect(info.count == 1)
32+
33+
if case let .step(timestamp, percent, text) = info.first {
34+
#expect(text == "Processing item 1")
35+
#expect(percent == 10)
36+
#expect(timestamp.timeIntervalSince1970 > 0)
37+
} else {
38+
Issue.record("Expected step info but got \(info[0])")
39+
return
40+
}
2441
}
2542

2643
@Test("Test complete method writes completion status")
27-
func testCompleteWritesCompletionStatus() throws {
44+
func testCompleteWritesCompletionStatus() async throws {
2845
let tempFile = fs.mktemp(ext: ".json")
29-
let reporter = JsonFileProgressReporter(filePath: tempFile)
30-
31-
reporter.complete(success: true)
32-
33-
let fileContent = try String(contentsOfFile: tempFile.string)
34-
35-
#expect(fileContent.contains("\"success\":true"))
36-
#expect(fileContent.contains("\"complete\""))
46+
try await fs.create(.mode(Int(0o644)), file: tempFile)
47+
defer { try? FileManager.default.removeItem(atPath: tempFile.string) }
3748

38-
try FileManager.default.removeItem(atPath: tempFile.string)
39-
}
49+
let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile)
4050

41-
@Test("Test complete method writes failure status")
42-
func testCompleteWritesFailureStatus() throws {
43-
let tempFile = fs.mktemp(ext: ".json")
44-
let reporter = JsonFileProgressReporter(filePath: tempFile)
51+
let status = Bool.random()
52+
reporter.complete(success: status)
53+
try reporter.close()
4554

46-
reporter.complete(success: false)
55+
let decoder = JSONDecoder()
4756

48-
let fileContent = try String(contentsOfFile: tempFile.string)
57+
let info = try String(contentsOfFile: tempFile.string).split(separator: "\n")
58+
.filter {
59+
!$0.isEmpty
60+
}.map {
61+
try decoder.decode(ProgressInfo.self, from: Data($0.utf8))
62+
}
4963

50-
#expect(fileContent.contains("\"success\":false"))
51-
#expect(fileContent.contains("\"complete\""))
64+
#expect(info.count == 1)
5265

53-
try FileManager.default.removeItem(atPath: tempFile.string)
66+
if case let .complete(success) = info.first {
67+
#expect(success == status)
68+
} else {
69+
Issue.record("Expected completion info but got \(info)")
70+
return
71+
}
5472
}
5573

5674
@Test("Test percentage calculation")
57-
func testPercentageCalculation() throws {
75+
func testPercentageCalculation() async throws {
5876
let tempFile = fs.mktemp(ext: ".json")
59-
let reporter = JsonFileProgressReporter(filePath: tempFile)
77+
try await fs.create(.mode(Int(0o644)), file: tempFile)
78+
defer { try? FileManager.default.removeItem(atPath: tempFile.string) }
79+
let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile)
6080

6181
reporter.update(step: 25, total: 100, text: "Quarter way")
62-
63-
let fileContent = try String(contentsOfFile: tempFile.string)
64-
65-
#expect(fileContent.contains("\"percent\":25"))
82+
try reporter.close()
83+
84+
let decoder = JSONDecoder()
85+
let info = try String(contentsOfFile: tempFile.string).split(separator: "\n")
86+
.filter {
87+
!$0.isEmpty
88+
}.map {
89+
try decoder.decode(ProgressInfo.self, from: Data($0.utf8))
90+
}
91+
#expect(info.count == 1)
92+
if case let .step(_, percent, text) = info.first {
93+
#expect(percent == 25)
94+
#expect(text == "Quarter way")
95+
} else {
96+
Issue.record("Expected step info but got \(info)")
97+
return
98+
}
6699

67100
try FileManager.default.removeItem(atPath: tempFile.string)
68101
}
69102

70-
@Test("Test clear method removes file")
71-
func testClearRemovesFile() throws {
103+
@Test("Test clear method truncates the file")
104+
func testClearTruncatesFile() async throws {
72105
let tempFile = fs.mktemp(ext: ".json")
73-
let reporter = JsonFileProgressReporter(filePath: tempFile)
106+
try await fs.create(.mode(Int(0o644)), file: tempFile)
107+
defer { try? FileManager.default.removeItem(atPath: tempFile.string) }
108+
let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile)
109+
defer { try? reporter.close() }
74110

75111
reporter.update(step: 1, total: 2, text: "Test")
76112

77-
#expect(FileManager.default.fileExists(atPath: tempFile.string))
113+
#expect(try String(contentsOf: tempFile).lengthOfBytes(using: String.Encoding.utf8) > 0)
78114

79115
reporter.clear()
80116

81-
#expect(!FileManager.default.fileExists(atPath: tempFile.string))
117+
#expect(try String(contentsOf: tempFile).lengthOfBytes(using: String.Encoding.utf8) == 0)
82118
}
83119

84120
@Test("Test multiple progress updates create multiple lines")
85-
func testMultipleUpdatesCreateMultipleLines() throws {
121+
func testMultipleUpdatesCreateMultipleLines() async throws {
86122
let tempFile = fs.mktemp(ext: ".json")
87-
let reporter = JsonFileProgressReporter(filePath: tempFile)
123+
try await fs.create(.mode(Int(0o644)), file: tempFile)
124+
defer { try? FileManager.default.removeItem(atPath: tempFile.string) }
88125

89-
reporter.update(step: 1, total: 3, text: "Step 1")
90-
reporter.update(step: 2, total: 3, text: "Step 2")
91-
reporter.complete(success: true)
126+
let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile)
92127

93-
let fileContent = try String(contentsOfFile: tempFile.string)
94-
let lines = fileContent.components(separatedBy: .newlines).filter { !$0.isEmpty }
128+
reporter.update(step: 5, total: 100, text: "Processing item 5")
129+
reporter.update(step: 10, total: 100, text: "Processing item 10")
130+
reporter.update(step: 50, total: 100, text: "Processing item 50")
131+
reporter.update(step: 100, total: 100, text: "Processing item 100")
95132

96-
#expect(lines.count == 3)
97-
#expect(lines[0].contains("Step 1"))
98-
#expect(lines[1].contains("Step 2"))
99-
#expect(lines[2].contains("\"success\":true"))
100-
101-
try FileManager.default.removeItem(atPath: tempFile.string)
102-
}
103-
104-
@Test("Test zero step edge case")
105-
func testZeroStepEdgeCase() throws {
106-
let tempFile = fs.mktemp(ext: ".json")
107-
let reporter = JsonFileProgressReporter(filePath: tempFile)
108-
109-
reporter.update(step: 0, total: 10, text: "Starting")
110-
111-
let fileContent = try String(contentsOfFile: tempFile.string)
112-
113-
#expect(fileContent.contains("\"percent\":0"))
114-
#expect(fileContent.contains("Starting"))
115-
116-
try FileManager.default.removeItem(atPath: tempFile.string)
117-
}
118-
119-
@Test("Test full completion edge case")
120-
func testFullCompletionEdgeCase() throws {
121-
let tempFile = fs.mktemp(ext: ".json")
122-
let reporter = JsonFileProgressReporter(filePath: tempFile)
123-
124-
reporter.update(step: 100, total: 100, text: "Done")
125-
126-
let fileContent = try String(contentsOfFile: tempFile.string)
127-
128-
#expect(fileContent.contains("\"percent\":100"))
129-
#expect(fileContent.contains("Done"))
130-
131-
try FileManager.default.removeItem(atPath: tempFile.string)
133+
reporter.complete(success: true)
134+
try? reporter.close()
135+
136+
let decoder = JSONDecoder()
137+
let info = try String(contentsOfFile: tempFile.string).split(separator: "\n")
138+
.filter {
139+
!$0.isEmpty
140+
}.map {
141+
try decoder.decode(ProgressInfo.self, from: Data($0.utf8))
142+
}
143+
144+
#expect(info.count == 5)
145+
146+
for (idx, pct) in [5, 10, 50, 100].enumerated() {
147+
if case let .step(_, percent, text) = info[idx] {
148+
#expect(text == "Processing item \(pct)")
149+
#expect(percent == pct)
150+
} else {
151+
Issue.record("Expected step info but got \(info[idx])")
152+
return
153+
}
154+
}
155+
156+
if case let .complete(success) = info[4] {
157+
#expect(success == true)
158+
} else {
159+
Issue.record("Expected completion info but got \(info[4])")
160+
return
161+
}
132162
}
133163
}

0 commit comments

Comments
 (0)