diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b219a92..199231a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,11 +5,9 @@ # Category C (tools-version 6.3): macos-15 + 6.3, macos-26 + 6.3 # Linux: ubuntu + Swift 6.3 # -# This package is Category A, plus a Linux leg on Swift 6.3. Test names are raw +# This package is Category A, plus a Linux leg. Test names are raw # identifiers (SE-0451), so a leg must be on Swift 6.2 or newer to run `swift test`; # a leg on an older compiler is limited to `swift build -v`. -# -# When Swift 6.4 ships: add 6.4 legs alongside 6.2 name: CI @@ -32,8 +30,12 @@ jobs: include: - os: macos-26 swift: "6.2" + - os: macos-26 + swift: "6.4" - os: ubuntu-latest swift: "6.3" + - os: ubuntu-latest + swift: "6.4" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index bd53185..0dcc62c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Change Log +## [Unreleased] + +### Changed + +- Adopt typed throws across the parsing surface: `DOF.init(data:)`, `DOF.init(url:)`, the `DOF.from(…)` factories, and `DOFByteParser` now declare `throws(DOFError)`, and the DOF file line reader's `AsyncIteratorProtocol.Failure` is `DOFError`. `AsyncBytesLineReader` propagates its source sequence's own `Failure` type. +- `DOF.from(filePath:)` streams the file in chunks instead of reading it into memory in its entirety. A file that cannot be opened now throws `DOFError.fileNotFound` rather than a Foundation file-read error. +- Match the header's "CURRENCY DATE = " marker against an `InlineArray<16, UInt8>`, removing a heap allocation from currency date parsing. + +### Fixed + +- `Cycle.previous`, `Cycle.next`, and the cycle datum date no longer force-unwrap optionals. + ## [1.3.0] - 2026-09-14 ### Changed diff --git a/Package.swift b/Package.swift index 71bf0e7..fbab8a3 100644 --- a/Package.swift +++ b/Package.swift @@ -9,13 +9,14 @@ let upcomingFeatures: [SwiftSetting] = [ .enableUpcomingFeature("ImmutableWeakCaptures"), .enableUpcomingFeature("MemberImportVisibility"), .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("InternalImportsByDefault") + .enableUpcomingFeature("InternalImportsByDefault"), + .strictMemorySafety() ] let package = Package( name: "SwiftDOF", defaultLocalization: "en", - platforms: [.macOS(.v15), .iOS(.v18), .watchOS(.v11), .tvOS(.v18), .visionOS(.v2)], + platforms: [.macOS(.v26), .iOS(.v26), .watchOS(.v26), .tvOS(.v26), .visionOS(.v26)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( diff --git a/README.md b/README.md index 2f4b108..7e795fc 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ The DOF format is documented at Self? { + guard let firstDate, + let shiftedDate = Self.calendar.date(byAdding: .day, value: days, to: firstDate) + else { + return nil + } + return Self( + dateComponents: Self.calendar.dateComponents([.year, .month, .day], from: shiftedDate) + ) + } } extension Cycle: LosslessStringConvertible { @@ -237,7 +241,7 @@ extension Cycle: Comparable { extension Cycle: Identifiable { /// The unique identifier for this cycle in YYYYMMDD format. public var id: String { - String(format: "%04d%02d%02d", year, month, day) + unsafe String(format: "%04d%02d%02d", year, month, day) } } diff --git a/Sources/SwiftDOF/DOF.swift b/Sources/SwiftDOF/DOF.swift index 2bc231b..fcc0e06 100644 --- a/Sources/SwiftDOF/DOF.swift +++ b/Sources/SwiftDOF/DOF.swift @@ -37,7 +37,7 @@ public struct DOF: Sendable, Codable { data: Data, progressHandler: @Sendable (Progress) -> Void = { _ in }, errorCallback: ((any Error, Int) -> Void)? = nil - ) throws { + ) throws(DOFError) { var obstacles: [String: Obstacle] = [:] obstacles.reserveCapacity(Self.estimatedObstacleCount) @@ -84,7 +84,7 @@ public struct DOF: Sendable, Codable { url: URL, progressHandler: @Sendable (Progress) -> Void = { _ in }, errorCallback: ((any Error, Int) -> Void)? = nil - ) async throws { + ) async throws(DOFError) { var obstacles: [String: Obstacle] = [:] obstacles.reserveCapacity(Self.estimatedObstacleCount) @@ -180,6 +180,47 @@ public struct DOF: Sendable, Codable { self.obstaclesByID = obstacles } + /// Creates a DOF container by streaming a file from disk without holding it all in memory. + private init( + streamingFrom url: URL, + progressHandler: @Sendable (Progress) -> Void, + errorCallback: ((any Error, Int) -> Void)? + ) throws(DOFError) { + var obstacles: [String: Obstacle] = [:] + obstacles.reserveCapacity(Self.estimatedObstacleCount) + + var lineNumber = 0 + var cycle: Cycle? + + var reader = FileLineReader(url: url) + + // Setup progress tracking based on file size + let progress = Progress(totalUnitCount: reader.fileSize ?? -1) + progressHandler(progress) + + while let line = try reader.next() { + lineNumber += 1 + try Self.processLine( + line[...], + lineNumber: lineNumber, + cycle: &cycle, + obstacles: &obstacles, + errorCallback: errorCallback + ) + progress.completedUnitCount = reader.bytesRead + } + if progress.totalUnitCount > 0 { + progress.completedUnitCount = progress.totalUnitCount + } + + guard let cycle else { + throw DOFError.invalidFormat(.missingCurrencyDate) + } + + self.cycle = cycle + self.obstaclesByID = obstacles + } + /// Process a single line from the DOF file. private static func processLine( _ line: ArraySlice, @@ -187,7 +228,7 @@ public struct DOF: Sendable, Codable { cycle: inout Cycle?, obstacles: inout [String: Obstacle], errorCallback: ((any Error, Int) -> Void)? - ) throws { + ) throws(DOFError) { // Line 1: Parse currency date if lineNumber == 1 { cycle = try DOFByteParser.parseCurrencyDate(line) @@ -213,7 +254,7 @@ public struct DOF: Sendable, Codable { // MARK: - Static Factory Methods - /// Load DOF data from a file path (synchronous). + /// Load DOF data from a file path (synchronous), streaming the file from disk. /// /// - Parameters: /// - filePath: The URL of the DOF file. @@ -221,14 +262,17 @@ public struct DOF: Sendable, Codable { /// object that you can use to track parsing progress. /// - errorCallback: Optional callback for parse errors. /// - Returns: The parsed DOF. - /// - Throws: Error if loading or parsing fails. + /// - Throws: ``DOFError`` if loading or parsing fails. public static func from( filePath: URL, progressHandler: @Sendable (Progress) -> Void = { _ in }, errorCallback: ((any Error, Int) -> Void)? = nil - ) throws -> Self { - let data = try Data(contentsOf: filePath) - return try Self(data: data, progressHandler: progressHandler, errorCallback: errorCallback) + ) throws(DOFError) -> Self { + try Self( + streamingFrom: filePath, + progressHandler: progressHandler, + errorCallback: errorCallback + ) } /// Load DOF data from raw data. @@ -244,7 +288,7 @@ public struct DOF: Sendable, Codable { data: Data, progressHandler: @Sendable (Progress) -> Void = { _ in }, errorCallback: ((any Error, Int) -> Void)? = nil - ) throws -> Self { + ) throws(DOFError) -> Self { try Self(data: data, progressHandler: progressHandler, errorCallback: errorCallback) } @@ -261,7 +305,7 @@ public struct DOF: Sendable, Codable { url: URL, progressHandler: @Sendable (Progress) -> Void = { _ in }, errorCallback: ((any Error, Int) -> Void)? = nil - ) async throws -> Self { + ) async throws(DOFError) -> Self { try await Self(url: url, progressHandler: progressHandler, errorCallback: errorCallback) } diff --git a/Sources/SwiftDOF/Parser/ByteParsing.swift b/Sources/SwiftDOF/Parser/ByteParsing.swift index 5fd37ce..b652705 100644 --- a/Sources/SwiftDOF/Parser/ByteParsing.swift +++ b/Sources/SwiftDOF/Parser/ByteParsing.swift @@ -102,7 +102,7 @@ extension RandomAccessCollection where Element == UInt8, Index == Int { /// Convert to trimmed String (only when actually needed). /// - Throws: DOFError.invalidEncoding if bytes cannot be decoded as Latin-1. @inlinable - func toString() throws -> String { + func toString() throws(DOFError) -> String { guard let string = String(bytes: Array(self), encoding: .isoLatin1) else { throw DOFError.invalidEncoding } diff --git a/Sources/SwiftDOF/Parser/DOFByteParser.swift b/Sources/SwiftDOF/Parser/DOFByteParser.swift index 765eac1..86bfeb7 100644 --- a/Sources/SwiftDOF/Parser/DOFByteParser.swift +++ b/Sources/SwiftDOF/Parser/DOFByteParser.swift @@ -34,8 +34,13 @@ struct DOFByteParser: Sendable { /// Minimum line length required for parsing. static let minimumLineLength = 127 - /// Pattern to match in currency date header. - private static let currencyDatePattern: [UInt8] = Array("CURRENCY DATE = ".utf8) + /// The bytes of "CURRENCY DATE = ", the pattern preceding the date in the DOF header. + private static let currencyDatePattern: InlineArray<16, UInt8> = [ + UInt8(ascii: "C"), UInt8(ascii: "U"), UInt8(ascii: "R"), UInt8(ascii: "R"), + UInt8(ascii: "E"), UInt8(ascii: "N"), UInt8(ascii: "C"), UInt8(ascii: "Y"), + UInt8(ascii: " "), UInt8(ascii: "D"), UInt8(ascii: "A"), UInt8(ascii: "T"), + UInt8(ascii: "E"), UInt8(ascii: " "), UInt8(ascii: "="), UInt8(ascii: " ") + ] // MARK: Public API @@ -43,7 +48,7 @@ struct DOFByteParser: Sendable { static func parseLine( _ bytes: T, lineNumber: Int = 0 - ) throws -> Obstacle where T.Element == UInt8, T.Index == Int { + ) throws(DOFError) -> Obstacle where T.Element == UInt8, T.Index == Int { guard bytes.count >= minimumLineLength else { throw DOFError.lineTooShort( expected: minimumLineLength, @@ -170,26 +175,12 @@ struct DOFByteParser: Sendable { /// Expects format: "CURRENCY DATE = MM/DD/YY" static func parseCurrencyDate( _ bytes: T - ) throws -> Cycle where T.Element == UInt8, T.Index == Int { - // Find pattern - var matchStart: Int? - for i in bytes.startIndex..<(bytes.endIndex - currencyDatePattern.count) { - let slice = bytes[i..<(i + currencyDatePattern.count)] - guard zip(slice, currencyDatePattern).allSatisfy({ $0 == $1 }) else { continue } - matchStart = i + currencyDatePattern.count - break - } - - guard let start = matchStart else { + ) throws(DOFError) -> Cycle where T.Element == UInt8, T.Index == Int { + guard let start = currencyDateStart(in: bytes) else { throw DOFError.invalidFormat(.currencyDateHeaderNotFound) } - // Find slash positions in date portion - let dateBytes = bytes[start...] - var slashPositions: [Int] = [] - for (i, byte) in dateBytes.enumerated() where byte == ASCII.slash { - slashPositions.append(start + i) - } + let slashPositions = (start..= 2 else { throw DOFError.invalidFormat(.invalidCurrencyDateFormat) @@ -212,6 +203,25 @@ struct DOFByteParser: Sendable { // MARK: Private Helpers + /// The index just past the currency date pattern, or `nil` when the pattern is absent. + private static func currencyDateStart( + in bytes: T + ) -> Int? where T.Element == UInt8, T.Index == Int { + let patternLength = currencyDatePattern.count + guard bytes.count >= patternLength else { return nil } + + return (bytes.startIndex..<(bytes.endIndex - patternLength)) + .first { matchesCurrencyDatePattern(bytes, at: $0) } + .map { $0 + patternLength } + } + + private static func matchesCurrencyDatePattern( + _ bytes: T, + at start: Int + ) -> Bool where T.Element == UInt8, T.Index == Int { + currencyDatePattern.indices.allSatisfy { bytes[start + $0] == currencyDatePattern[$0] } + } + private static func slice( _ bytes: T, _ base: Int, @@ -224,7 +234,7 @@ struct DOFByteParser: Sendable { _ bytes: T, base: Int, lineNumber: Int - ) throws -> Double where T.Element == UInt8, T.Index == Int { + ) throws(DOFError) -> Double where T.Element == UInt8, T.Index == Int { let degSlice = slice(bytes, base, fields.latDegrees) let minSlice = slice(bytes, base, fields.latMinutes) let secSlice = slice(bytes, base, fields.latSeconds) @@ -268,7 +278,7 @@ struct DOFByteParser: Sendable { _ bytes: T, base: Int, lineNumber: Int - ) throws -> Double where T.Element == UInt8, T.Index == Int { + ) throws(DOFError) -> Double where T.Element == UInt8, T.Index == Int { let degSlice = slice(bytes, base, fields.lonDegrees) let minSlice = slice(bytes, base, fields.lonMinutes) let secSlice = slice(bytes, base, fields.lonSeconds) @@ -314,7 +324,7 @@ struct DOFByteParser: Sendable { private static func parseJulianDate( _ bytes: T, lineNumber: Int - ) throws -> DateComponents where T.Element == UInt8, T.Index == Int { + ) throws(DOFError) -> DateComponents where T.Element == UInt8, T.Index == Int { // Format: YYYYDDD (e.g., 2014138 = year 2014, day 138) let yearSlice = bytes.prefix(4) let daySlice = bytes.dropFirst(4) diff --git a/Sources/SwiftDOF/Parser/DOFLineReader.swift b/Sources/SwiftDOF/Parser/DOFLineReader.swift index 511902b..34af64e 100644 --- a/Sources/SwiftDOF/Parser/DOFLineReader.swift +++ b/Sources/SwiftDOF/Parser/DOFLineReader.swift @@ -26,10 +26,10 @@ struct DOFLineReader: Sequence, IteratorProtocol, Sendable { lineBuffer.removeAll(keepingCapacity: true) // Scan until LF or end of data - data.withUnsafeBytes { buffer in - let bytes = buffer.bindMemory(to: UInt8.self) + unsafe data.withUnsafeBytes { buffer in + let bytes = unsafe buffer.bindMemory(to: UInt8.self) while position < bytes.count { - let byte = bytes[position] + let byte = unsafe bytes[position] position += 1 if byte == ASCII.LF { return } lineBuffer.append(byte) @@ -45,13 +45,11 @@ struct DOFLineReader: Sequence, IteratorProtocol, Sendable { } } -// MARK: - AsyncDOFLineReader - -/// Async line reader for streaming DOF data from a file URL. -/// Reads in chunks to minimize memory usage for large files. -struct AsyncDOFLineReader: AsyncSequence, Sendable { - typealias Element = [UInt8] +// MARK: - FileLineReader +/// Line reader that streams DOF data from a file on disk. +/// Reads in chunks so a large file is never held in memory in its entirety. +struct FileLineReader: Sendable { /// Default read buffer size (64KB). static let defaultBufferSize = 65536 @@ -60,83 +58,117 @@ struct AsyncDOFLineReader: AsyncSequence, Sendable { private let url: URL private let bufferSize: Int + private var handle: FileHandle? + private var buffer: [UInt8] = [] + private var bufferPosition = 0 + private var lineBuffer: [UInt8] = [] + private var isAtEnd = false /// The total size of the file in bytes, if known. let fileSize: Int64? + /// Total bytes read from the file so far. + private(set) var bytesRead: Int64 = 0 + + private var bufferIsExhausted: Bool { bufferPosition >= buffer.count } + init(url: URL, bufferSize: Int = defaultBufferSize) { self.url = url self.bufferSize = bufferSize - // Try to get file size for progress tracking - if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), - let size = attrs[.size] as? Int64 - { - self.fileSize = size - } else { - self.fileSize = nil - } + self.fileSize = Self.sizeOfFile(at: url) + lineBuffer.reserveCapacity(Self.lineBufferCapacity) } - func makeAsyncIterator() -> AsyncIterator { - AsyncIterator(url: url, bufferSize: bufferSize) + /// The size in bytes of the file at `url`, if it can be determined. + static func sizeOfFile(at url: URL) -> Int64? { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else { + return nil + } + return attributes[.size] as? Int64 } - struct AsyncIterator: AsyncIteratorProtocol { - private let url: URL - private let bufferSize: Int - private var handle: FileHandle? - private var buffer: [UInt8] = [] - private var bufferPos: Int = 0 - private var lineBuffer: [UInt8] = [] - private var isEOF = false + /// Returns the next line, or `nil` once the file is exhausted. + mutating func next() throws(DOFError) -> [UInt8]? { + guard !isAtEnd else { return nil } - /// Total bytes read from the file so far. - private(set) var bytesRead: Int64 = 0 + let handle = try openedHandle() + lineBuffer.removeAll(keepingCapacity: true) - init(url: URL, bufferSize: Int) { - self.url = url - self.bufferSize = bufferSize - self.lineBuffer.reserveCapacity(lineBufferCapacity) - } + while true { + if bufferIsExhausted { + guard let chunk = try readChunk(from: handle), !chunk.isEmpty else { + isAtEnd = true + return lineBuffer.isEmpty ? nil : lineBuffer + } + bytesRead += Int64(chunk.count) + buffer = Array(chunk) + bufferPosition = 0 + } - mutating func next() throws -> [UInt8]? { - guard !isEOF else { return nil } + let byte = buffer[bufferPosition] + bufferPosition += 1 - // Lazily open file handle on first call - if handle == nil { - handle = try FileHandle(forReadingFrom: url) + if byte == ASCII.LF { + // Strip trailing CR if present (handles CRLF) + if lineBuffer.last == ASCII.CR { + lineBuffer.removeLast() + } + return lineBuffer } - guard let handle else { preconditionFailure("handle was nil") } + lineBuffer.append(byte) + } + } - lineBuffer.removeAll(keepingCapacity: true) + private mutating func openedHandle() throws(DOFError) -> FileHandle { + if let handle { return handle } + guard let opened = try? FileHandle(forReadingFrom: url) else { + throw DOFError.fileNotFound(url) + } + handle = opened + return opened + } - while true { - // Refill buffer if exhausted - if bufferPos >= buffer.count { - guard let chunk = try handle.read(upToCount: bufferSize), - !chunk.isEmpty - else { - isEOF = true - // Return any remaining content as final line - return lineBuffer.isEmpty ? nil : lineBuffer - } - bytesRead += Int64(chunk.count) - buffer = Array(chunk) - bufferPos = 0 - } + private func readChunk(from handle: FileHandle) throws(DOFError) -> Data? { + do { + return try handle.read(upToCount: bufferSize) + } catch { + throw DOFError.streamError(error) + } + } +} + +// MARK: - AsyncDOFLineReader - let byte = buffer[bufferPos] - bufferPos += 1 +/// Async façade over ``FileLineReader`` for `for await` iteration of a DOF file. +struct AsyncDOFLineReader: AsyncSequence, Sendable { + typealias Element = [UInt8] + typealias Failure = DOFError - if byte == ASCII.LF { - // Strip trailing CR if present (handles CRLF) - if lineBuffer.last == ASCII.CR { - lineBuffer.removeLast() - } - return lineBuffer - } - lineBuffer.append(byte) - } + private let url: URL + private let bufferSize: Int + + /// The total size of the file in bytes, if known. + let fileSize: Int64? + + init(url: URL, bufferSize: Int = FileLineReader.defaultBufferSize) { + self.url = url + self.bufferSize = bufferSize + self.fileSize = FileLineReader.sizeOfFile(at: url) + } + + func makeAsyncIterator() -> AsyncIterator { + AsyncIterator(reader: FileLineReader(url: url, bufferSize: bufferSize)) + } + + struct AsyncIterator: AsyncIteratorProtocol { + private var reader: FileLineReader + + init(reader: FileLineReader) { + self.reader = reader + } + + mutating func next() throws(DOFError) -> [UInt8]? { + try reader.next() } } } @@ -148,6 +180,7 @@ struct AsyncDOFLineReader: AsyncSequence, Sendable { struct AsyncBytesLineReader: AsyncSequence, Sendable where Source.Element == UInt8, Source: Sendable { typealias Element = [UInt8] + typealias Failure = Source.Failure /// Pre-allocated capacity for line buffer (DOF lines are ~128 bytes). private static var lineBufferCapacity: Int { 256 } @@ -172,10 +205,10 @@ where Source.Element == UInt8, Source: Sendable { } @concurrent - mutating func next() async throws -> [UInt8]? { + mutating func next() async throws(Source.Failure) -> [UInt8]? { lineBuffer.removeAll(keepingCapacity: true) - while let byte = try await iterator.next() { + while let byte = try await iterator.next(isolation: nil) { if byte == ASCII.LF { // Strip trailing CR if present (handles CRLF) if lineBuffer.last == ASCII.CR { diff --git a/Sources/SwiftDOF_E2E/OutputFormatter.swift b/Sources/SwiftDOF_E2E/OutputFormatter.swift index 0c6f5c4..1f79e19 100644 --- a/Sources/SwiftDOF_E2E/OutputFormatter.swift +++ b/Sources/SwiftDOF_E2E/OutputFormatter.swift @@ -17,9 +17,10 @@ protocol OutputFormatter { extension OutputStream { func write(_ string: String) { guard let data = string.data(using: .utf8) else { return } - data.withUnsafeBytes { buffer in - guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return } - write(pointer, maxLength: buffer.count) + unsafe data.withUnsafeBytes { buffer in + guard let pointer = unsafe buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { return } + unsafe write(pointer, maxLength: buffer.count) } } @@ -48,9 +49,10 @@ struct JSONOutputFormatter: OutputFormatter { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let jsonData = try encoder.encode(dof.all) - jsonData.withUnsafeBytes { buffer in - guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return } - stream.write(pointer, maxLength: buffer.count) + unsafe jsonData.withUnsafeBytes { buffer in + guard let pointer = unsafe buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { return } + unsafe stream.write(pointer, maxLength: buffer.count) } } } diff --git a/Sources/SwiftDOF_E2E/SwiftDOF_E2E.swift b/Sources/SwiftDOF_E2E/SwiftDOF_E2E.swift index 9d87e91..9aeb685 100644 --- a/Sources/SwiftDOF_E2E/SwiftDOF_E2E.swift +++ b/Sources/SwiftDOF_E2E/SwiftDOF_E2E.swift @@ -42,7 +42,7 @@ struct SwiftDOF_E2E: AsyncParsableCommand { else { fatalError("Current cycle could not be determined") } - let filename = String(format: "DOF_%02d%02d%02d.zip", year % 100, month, day) + let filename = unsafe String(format: "DOF_%02d%02d%02d.zip", year % 100, month, day) guard let url = URL(string: "https://aeronav.faa.gov/Obst_Data/\(filename)") else { fatalError("Current DOF URL could not be determined") } diff --git a/Tests/SwiftDOFTests/DOFTests.swift b/Tests/SwiftDOFTests/DOFTests.swift index 1c1ad44..e49ffb1 100644 --- a/Tests/SwiftDOFTests/DOFTests.swift +++ b/Tests/SwiftDOFTests/DOFTests.swift @@ -23,6 +23,11 @@ struct DOFTests { sampleDOFContent.data(using: .utf8)! } + /// A URL in the temporary directory that no file occupies. + private static func temporaryFileURL() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).dat") + } + @Test func `parses obstacles and the currency date from DOF data`() throws { let dof = try DOF(data: sampleDOFData) @@ -172,6 +177,41 @@ struct DOFTests { #expect(dof.count == 3) } + @Test + func `parses obstacles streamed from a file on disk`() throws { + let dof = try withTemporaryFile(containing: sampleDOFContent) { + try DOF.from(filePath: $0) + } + + #expect(dof.count == 3) + #expect(dof.cycle.year == 2025) + } + + @Test(arguments: [1, 7, 64, 4096]) + func `reads the same lines from a file whatever the buffer size`(_ bufferSize: Int) throws { + let lines = try withTemporaryFile(containing: sampleDOFContent) { url in + var reader = FileLineReader(url: url, bufferSize: bufferSize) + var lines: [[UInt8]] = [] + while let line = try reader.next() { + lines.append(line) + } + return lines + } + + #expect(lines == sampleDOFContent.split(separator: "\n").map { Array($0.utf8) }) + } + + @Test + func `throws a file-not-found error for a missing file`() { + let missingFile = Self.temporaryFileURL() + + let error = #expect(throws: DOFError.self) { + try DOF.from(filePath: missingFile) + } + + #expect(error?.isFileNotFound == true) + } + @Test func `leaves the error callback uncalled for valid data`() throws { var errorCalled = false @@ -186,4 +226,22 @@ struct DOFTests { #expect(dof.count == 3) #expect(!errorCalled) // No errors in valid content } + + /// Writes `content` to a temporary file, hands its URL to `body`, and removes the file after. + private func withTemporaryFile( + containing content: String, + _ body: (URL) throws -> T + ) throws -> T { + let url = Self.temporaryFileURL() + try content.write(to: url, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + return try body(url) + } +} + +extension DOFError { + fileprivate var isFileNotFound: Bool { + if case .fileNotFound = self { return true } + return false + } }