diff --git a/CHANGELOG.md b/CHANGELOG.md index 8976765..8301789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Features + +- Feature: Alternative `readFile` and `readFileInChunks` methods with optional `length` and `offset` parameters. + ## 1.0.1 ### Fixes diff --git a/IONFilesystemLib/IONFILEChunkPublisher.swift b/IONFilesystemLib/IONFILEChunkPublisher.swift index cc435bf..0e7cab3 100644 --- a/IONFilesystemLib/IONFILEChunkPublisher.swift +++ b/IONFilesystemLib/IONFILEChunkPublisher.swift @@ -8,15 +8,19 @@ public class IONFILEChunkPublisher: Publisher { private let url: URL private let chunkSize: Int private let encoding: IONFILEEncoding + private let offset: Int + private let length: Int - init(_ url: URL, _ chunkSize: Int, _ encoding: IONFILEEncoding) { + init(_ url: URL, _ chunkSize: Int, _ encoding: IONFILEEncoding, _ offset: Int = 0, _ length: Int = -1) { self.url = url self.chunkSize = chunkSize self.encoding = encoding + self.offset = offset + self.length = length } public func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { - let subscription = IONFILEChunkSubscription(url, chunkSize, encoding, subscriber) + let subscription = IONFILEChunkSubscription(url, chunkSize, encoding, offset, length, subscriber) subscriber.receive(subscription: subscription) } } @@ -25,24 +29,46 @@ private class IONFILEChunkSubscription: Subscription where S.Inpu private let fileHandle: FileHandle? private let chunkSize: Int private let encoding: IONFILEEncoding + private let offset: Int + private let length: Int private let subscriber: S private var isCompleted = false - init(_ url: URL, _ chunkSize: Int, _ encoding: IONFILEEncoding, _ subscriber: S) { + init(_ url: URL, _ chunkSize: Int, _ encoding: IONFILEEncoding, _ offset: Int = 0, _ length: Int = -1, _ subscriber: S) { self.fileHandle = try? FileHandle(forReadingFrom: url) self.chunkSize = chunkSize self.encoding = encoding self.subscriber = subscriber + self.offset = offset + self.length = length } func request(_ demand: Subscribers.Demand) { guard let fileHandle = fileHandle, !isCompleted else { return subscriber.receive(completion: .failure(IONFILEChunkPublisherError.notAbleToReadFile)) } + + if (offset > 0) { + do { + try fileHandle.seek(toOffset: UInt64(offset)) + } catch { + complete(withValue: .failure(error)) + return + } + } + var remainingToRead = Int.max + if (length > 0) { + remainingToRead = length + } while demand > .none { do { - if let chunk = try fileHandle.read(upToCount: chunkSize), !chunk.isEmpty { + var readCount = chunkSize + if (length > 0) { + readCount = min(readCount, remainingToRead) + } + if readCount > 0, let chunk = try fileHandle.read(upToCount: readCount), !chunk.isEmpty { + remainingToRead -= chunk.count let chunkToEmit: IONFILEEncodingValueMapper switch encoding { case .byteBuffer: chunkToEmit = .byteBuffer(value: chunk) diff --git a/IONFilesystemLib/IONFILEManager+Protocols.swift b/IONFilesystemLib/IONFILEManager+Protocols.swift index 08dfdc7..a164be4 100644 --- a/IONFilesystemLib/IONFILEManager+Protocols.swift +++ b/IONFilesystemLib/IONFILEManager+Protocols.swift @@ -9,6 +9,8 @@ public protocol IONFILEDirectoryManager { public protocol IONFILEFileManager { func readEntireFile(atURL: URL, withEncoding: IONFILEEncoding) throws -> IONFILEEncodingValueMapper func readFileInChunks(atURL: URL, withEncoding: IONFILEEncoding, andChunkSize: Int) throws -> IONFILEChunkPublisher + func readEntireFile(atURL: URL, withEncoding: IONFILEEncoding, andOffset: Int, andLength: Int) throws -> IONFILEEncodingValueMapper + func readFileInChunks(atURL: URL, withEncoding: IONFILEEncoding, andChunkSize: Int, andOffset: Int, andLength: Int) throws -> IONFILEChunkPublisher func getFileURL(atPath: String, withSearchPath: IONFILESearchPath) throws -> URL func deleteFile(atURL: URL) throws func saveFile(atURL: URL, withEncodingAndData: IONFILEEncodingValueMapper, includeIntermediateDirectories: Bool) throws diff --git a/IONFilesystemLib/IONFILEManager.swift b/IONFilesystemLib/IONFILEManager.swift index da16c85..71a2e65 100644 --- a/IONFilesystemLib/IONFILEManager.swift +++ b/IONFilesystemLib/IONFILEManager.swift @@ -47,24 +47,35 @@ extension IONFILEManager: IONFILEDirectoryManager { extension IONFILEManager: IONFILEFileManager { public func readEntireFile(atURL fileURL: URL, withEncoding encoding: IONFILEEncoding) throws -> IONFILEEncodingValueMapper { + try readEntireFile(atURL: fileURL, withEncoding: encoding, andOffset: 0, andLength: -1) + } + + public func readFileInChunks(atURL fileURL: URL, withEncoding encoding: IONFILEEncoding, andChunkSize chunkSize: Int) throws -> IONFILEChunkPublisher { + try readFileInChunks(atURL: fileURL, withEncoding: encoding, andChunkSize: chunkSize, andOffset: 0, andLength: -1) + } + + public func readEntireFile(atURL fileURL: URL, withEncoding encoding: IONFILEEncoding, andOffset offset: Int, andLength length: Int) throws -> IONFILEEncodingValueMapper { try withSecurityScopedAccess(to: fileURL) { let result: IONFILEEncodingValueMapper - switch encoding { - case .byteBuffer: - let fileData = try readFileAsByteBuffer(from: fileURL) - result = .byteBuffer(value: fileData) - case .string(let stringEncoding): - let fileData = try readFileAsString(from: fileURL, using: stringEncoding.stringEncoding) - result = .string(encoding: stringEncoding, value: fileData) + if (offset > 0 || length > 0) { + result = try readPartialFile(fileURL, encoding, offset, length) + } else { + switch encoding { + case .byteBuffer: + let fileData = try readFileAsByteBuffer(from: fileURL) + result = .byteBuffer(value: fileData) + case .string(let stringEncoding): + let fileData = try readFileAsString(from: fileURL, using: stringEncoding.stringEncoding) + result = .string(encoding: stringEncoding, value: fileData) + } } - return result } } - - public func readFileInChunks(atURL fileURL: URL, withEncoding encoding: IONFILEEncoding, andChunkSize chunkSize: Int) throws -> IONFILEChunkPublisher { + + public func readFileInChunks(atURL fileURL: URL, withEncoding encoding: IONFILEEncoding, andChunkSize chunkSize: Int, andOffset offset: Int, andLength length: Int) throws -> IONFILEChunkPublisher { try withSecurityScopedAccess(to: fileURL) { - .init(fileURL, chunkSize, encoding) + .init(fileURL, chunkSize, encoding, offset, length) } } @@ -179,6 +190,35 @@ private extension IONFILEManager { return try operation() } + + func readPartialFile(_ fileURL: URL, _ encoding: IONFILEEncoding, _ offset: Int, _ length: Int) throws -> IONFILEEncodingValueMapper { + let fileHandle = try FileHandle(forReadingFrom: fileURL) + defer { + try? fileHandle.close() + } + + if (offset > 0) { + try fileHandle.seek(toOffset: UInt64(offset)) + } + let data: Data? + if (length > 0) { + data = try fileHandle.read(upToCount: length) + } else { + data = try fileHandle.readToEnd() + } + + let nonNilData: Data = data ?? Data() + + switch encoding { + case .byteBuffer: + return .byteBuffer(value: nonNilData) + case .string(let stringEncoding): + guard let stringData = String(data: nonNilData, encoding: stringEncoding.stringEncoding) else { + throw IONFILEFileManagerError.cantDecodeData(usingEncoding: stringEncoding) + } + return .string(encoding: stringEncoding, value: stringData) + } + } func readFileAsByteBuffer(from fileURL: URL) throws -> Data { try Data(contentsOf: fileURL) diff --git a/IONFilesystemLibTests/IONFILEFileManagerTests.swift b/IONFilesystemLibTests/IONFILEFileManagerTests.swift index 4e928a7..654e48c 100644 --- a/IONFilesystemLibTests/IONFILEFileManagerTests.swift +++ b/IONFilesystemLibTests/IONFILEFileManagerTests.swift @@ -53,6 +53,63 @@ extension IONFILEFileManagerTests { // When and Then XCTAssertThrowsError(try fetchEntireContent(forURL: fileURL, withEncoding: .string(encoding: .utf8))) } + + func test_readEntireFile_offsetAndLengthWithExampleEncoding_returnsCorrectData() throws { + // Given + createFileManager() + let fileURL = try XCTUnwrap(fetchConfigurationFile()) + let offset = 7 + let length = 5 + let encoding: IONFILEStringEncoding = .utf8 + + // When + let result = try sut.readEntireFile(atURL: fileURL, withEncoding: .string(encoding: encoding), andOffset: offset, andLength: length) + + // Then + guard case .string(let resultEncoding, let resultValue) = result else { + XCTFail("Wrong result type") + return + } + XCTAssertEqual(resultEncoding, encoding) + XCTAssertEqual(resultValue, "world") + } + + func test_readEntire_offsetAndLengthWithByteBufferEncoding_returnsCorrectData() throws { + // Given + createFileManager() + let fileURL = try XCTUnwrap(fetchConfigurationFile()) + let offset = 7 + let length = 5 + + // When + let result = try sut.readEntireFile(atURL: fileURL, withEncoding: .byteBuffer, andOffset: offset, andLength: length) + + // Then + guard case .byteBuffer(let resultValue) = result else { + XCTFail("Wrong result type") + return + } + XCTAssertEqual(String(data: resultValue, encoding: .utf8), "world") + } + + func test_readEntireFile_offsetLargerThanEOF_returnsEmptyString() throws { + // Given + createFileManager() + let fileURL = try XCTUnwrap(fetchConfigurationFile()) + let offset = 1000 + let length = 5 + let encoding: IONFILEStringEncoding = .utf8 + + // When + let result = try sut.readEntireFile(atURL: fileURL, withEncoding: .string(encoding: encoding), andOffset: offset, andLength: length) + + // Then + guard case .string(let resultEncoding, let resultValue) = result else { + XCTFail("Wrong result type") + return + } + XCTAssertEqual(resultValue, "") + } } // MARK: - 'readFileInChunks' tests @@ -104,6 +161,36 @@ extension IONFILEFileManagerTests { // When and Then XCTAssertThrowsError(try fetchChunkedContent(forURL: fileURL, withEncoding: .string(encoding: .utf8))) } + + func test_readFileInChunks_withOffsetAndLength_returnsContentSuccessfully() throws { + // Given + createFileManager() + let offset = 7 + let length = 5 + + // When + let fileContent = try fetchChunkedContent( + forFile: (Configuration.fileName, Configuration.fileExtension), withEncoding: .string(encoding: .utf8), andOffset: offset, andLength: length + ) + + // Then + XCTAssertEqual(fileContent, "world") + } + + func test_readFileInChunks_offsetLargerThanEOF_returnsEmptyString() throws { + // Given + createFileManager() + let offset = 1000 + let length = 5 + + // When + let fileContent = try fetchChunkedContent( + forFile: (Configuration.fileName, Configuration.fileExtension), withEncoding: .string(encoding: .utf8), andOffset: offset, andLength: length + ) + + // Then + XCTAssertEqual(fileContent, "") + } } // MARK: - 'getFileURL' tests @@ -870,13 +957,13 @@ private extension IONFILEFileManagerTests { return try treat(content: content, withEncoding: encoding) } - func fetchChunkedContent(forFile file: (name: String, extension: String), withEncoding encoding: IONFILEEncoding, forceURLError: Bool = false) throws -> String { + func fetchChunkedContent(forFile file: (name: String, extension: String), withEncoding encoding: IONFILEEncoding, forceURLError: Bool = false, andOffset offset: Int = 0, andLength length: Int = -1) throws -> String { let fileURL = try XCTUnwrap(Bundle(for: type(of: self)).url(forResource: file.name, withExtension: file.extension)) - return try fetchChunkedContent(forURL: fileURL, withEncoding: encoding, forceURLError: forceURLError) + return try fetchChunkedContent(forURL: fileURL, withEncoding: encoding, forceURLError: forceURLError, andOffset: offset, andLength: length) } @discardableResult - func fetchChunkedContent(forURL url: URL, withEncoding encoding: IONFILEEncoding, forceURLError: Bool = false) throws -> String { + func fetchChunkedContent(forURL url: URL, withEncoding encoding: IONFILEEncoding, forceURLError: Bool = false, andOffset offset: Int = 0, andLength length: Int = -1) throws -> String { var fileURL = url var contentArray = [String]() var error: Error? @@ -885,7 +972,7 @@ private extension IONFILEFileManagerTests { if forceURLError { fileURL.deleteLastPathComponent() } - try sut.readFileInChunks(atURL: fileURL, withEncoding: encoding, andChunkSize: 3) // 3 bytes + try sut.readFileInChunks(atURL: fileURL, withEncoding: encoding, andChunkSize: 3, andOffset: offset, andLength: length) // 3 bytes .sink(receiveCompletion: { completion in if case .failure(let failure) = completion { error = failure