import Foundation enum FileAccessError: Error { case failedToReadFile(String, Error) } final class FileAccess { static let accessTimesFileName = "access.json" let errorOutput: ErrorOutput let sourceFolder: URL private let source = "FileAccess" private var modificationTimeCacheFile: URL { sourceFolder.appendingPathComponent(FileAccess.accessTimesFileName) } /** The time stamps of last modified times for all accessed source files. The key is the relative path to the file from the source */ private var sourceLastModifiedTimes: [String : Date] = [:] private var changedFiles: Set = [] private var accessedFiles: Set = [] init(in root: URL, errorOutput: ErrorOutput) { self.sourceFolder = root self.errorOutput = errorOutput loadSavedModificationTimes() } private func loadSavedModificationTimes() { let url = modificationTimeCacheFile guard url.exists else { errorOutput.add(info: "No file modification times loaded, regarding all content as new", source: source) return } let data: Data do { data = try Data(contentsOf: url) } catch { errorOutput.add( warning: "File modification times data not read, regarding all content as new", source: source, error: error) return } do { self.sourceLastModifiedTimes = try JSONDecoder().decode(from: data) } catch { errorOutput.add( warning: "File modification times not decoded, regarding all content as new", source: source, error: error) try? url.delete() return } } private func didAccess(inputPath: String, modified lastModified: Date, source: String) { accessedFiles.insert(inputPath) guard let previousDate = sourceLastModifiedTimes[inputPath] else { // File not processed before, so mark as changed changedFiles.insert(inputPath) return } guard lastModified > previousDate else { // File is unchanged return } changedFiles.insert(inputPath) } private func lastModifiedTime(of url: URL) -> Date? { guard url.exists else { return nil } do { let attributes = try FileManager.default.attributesOfItem(atPath: url.path) guard let date = attributes[.modificationDate] as? Date else { errorOutput.add(warning: "Failed to read modification time of \(url.path)", source: source) return nil } return date } catch { errorOutput.add(warning: "Failed to read file attributes of \(url.path)", source: source) return nil } } func loadStringContent(inputPath: String) throws -> String? { try load(inputPath: inputPath, String.init) } func loadDataContent(inputPath: String) throws -> Data? { try load(inputPath: inputPath) { try Data(contentsOf: $0) } } private func load(inputPath: String, _ closure: (URL) throws -> T) rethrows -> T? { let url = sourceFolder.appendingPathComponent(inputPath) guard let modifiedDate = lastModifiedTime(of: url) else { sourceLastModifiedTimes[inputPath] = nil return nil } do { let content = try closure(url) didAccess(inputPath: inputPath, modified: modifiedDate, source: source) return content } catch { throw FileAccessError.failedToReadFile(inputPath, error) } } func didGenerateAllFiles() { for file in changedFiles { let url = sourceFolder.appendingPathComponent(file) guard let date = lastModifiedTime(of: url) else { continue } sourceLastModifiedTimes[file] = date } do { let data = try JSONEncoder().encode(sourceLastModifiedTimes) try data.write(to: modificationTimeCacheFile) } catch { errorOutput.add(warning: "Failed to save modification times", source: source, error: error) } } func printChangedFilesOverview() { let count = changedFiles.count guard count > 0 else { print("No files modified") return } print("\(count) files modified:") changedFiles.prefix(10).forEach { print(" " + $0) } if count > 10 { print(" ...") } } func printAccessedFilesOverview() { let count = accessedFiles.count guard count > 0 else { print("No files accessed") return } print("\(count) files accessed:") accessedFiles.prefix(10).forEach { print(" " + $0) } if count > 10 { print(" ...") } } func printAllTouchedFiles() { print("\(accessedFiles.count) files accessed:") accessedFiles.sorted().forEach { file in if changedFiles.contains(file) { print(" \(file) (changed)") } else { print(" " + file) } } } }