diff --git a/Sources/BinaryDependencyManager/Utils/BinaryDependenciesConfigurationReader.swift b/Sources/BinaryDependencyManager/Utils/BinaryDependenciesConfigurationReader.swift new file mode 100644 index 0000000..1546362 --- /dev/null +++ b/Sources/BinaryDependencyManager/Utils/BinaryDependenciesConfigurationReader.swift @@ -0,0 +1,128 @@ +import Foundation +import Yams +import ArgumentParser + + +/// A utility for resolving and loading binary dependencies configuration files. +struct BinaryDependenciesConfigurationReader { + private static let defaultConfigurationFilenames: [String] = [ + ".binary-dependencies.yaml", + ".binary-dependencies.yml", + "Dependencies.json" // CMM, Setapp + ] + + private static let defaultOutputDirectoryPaths: [String] = [ + "Dependencies/Binary", + "Dependencies", // CMM, Setapp + ] + + private static let defaultCacheDirectoryPaths: [String] = [ + ".cache/binary-dependencies", + ".cache/dependencies", // Setapp + ".dependencies-cache", // CMM + ] + + private let fileManager: FileManagerProtocol + + init(fileManager: FileManagerProtocol = FileManager.default) { + self.fileManager = fileManager + } + + /// Resolves the file path based on the provided path, or searches through the specified variations. + /// - Parameters: + /// - filePath: The path to resolve, or nil to search through variations. + /// - variations: A list of possible filenames or directory names to check if filePath is nil. + /// - Returns: The resolved file URL. Crashes if no path is found. + private func resolveFilePath(_ filePath: String?, variations: [String]) -> URL { + let existingFileURL: URL? = filePath.map(\.asFileURL) + guard existingFileURL == .none else { return existingFileURL! } + + let fileURL = (variations.first(where: fileManager.fileExists(atPath:)) ?? variations.first).map(\.asFileURL) + + guard let fileURL else { + preconditionFailure("Path must be always resolved") + } + + return fileURL + } + + /// Resolves the configuration file URL, ensuring the file exists on disk. + /// - Parameter configurationFilePath: Optional configuration file path to use, or nil to search defaults. + /// - Throws: ValidationError if the file does not exist. + /// - Returns: The resolved configuration file URL. + func resolveConfigurationFileURL(_ configurationFilePath: String?) throws -> URL { + let configurationFileURL = resolveFilePath(configurationFilePath, variations: Self.defaultConfigurationFilenames) + guard fileManager.fileExists(atPath: configurationFileURL.path) else { + throw ValidationError("No configuration file found") + } + return configurationFileURL + } + + /// Resolves the output directory URL using the provided path, or falls back to the default output directories. + /// - Parameter outputDirectory: Optional output directory path. + /// - Returns: The resolved output directory URL. + func resolveOutputDirectoryURL(_ outputDirectory: String?) -> URL { + resolveFilePath(outputDirectory, variations: Self.defaultOutputDirectoryPaths) + } + + /// Resolves the cache directory URL using the provided path, or falls back to the default cache directories. + /// - Parameter cacheDirectory: Optional cache directory path. + /// - Returns: The resolved cache directory URL. + func resolveCacheDirectoryURL(_ cacheDirectory: String?) -> URL { + resolveFilePath(cacheDirectory, variations: Self.defaultCacheDirectoryPaths) + } + + /// Parses and returns a `BinaryDependenciesConfiguration` from the specified configuration file path. + /// - Parameter configurationPath: Optional path to the configuration file. + /// - Throws: An error if the file cannot be found, decoded, or the version check fails. + /// - Returns: The parsed `BinaryDependenciesConfiguration` object. + func readConfiguration(at configurationPath: String?) throws -> BinaryDependenciesConfiguration { + + let configurationURL: URL = try resolveConfigurationFileURL(configurationPath) + + // Get the contents of the file + guard let dependenciesData: Data = fileManager.contents(atPath: configurationURL.path) else { + throw ValidationError("Can't get contents of configuration file at \(configurationURL.path)") + } + + // Decoder selection: Check if this is yaml, and fallback to JSONDecoder. + let decoder: TopLevelDataDecoder + if ["yaml", "yml"].contains(configurationURL.pathExtension) { + decoder = YAMLDecoder() + } else { + decoder = JSONDecoder() + } + + // Parse configuration + let configuration = try decoder.decode(BinaryDependenciesConfiguration.self, from: dependenciesData) + + // Check minimum required version + let minimumRequiredVersion = configuration.minimumVersion ?? BinaryDependenciesManager.version + guard BinaryDependenciesManager.version >= minimumRequiredVersion else { + throw ValidationError( + "\(configurationPath ?? configurationURL.lastPathComponent) requires version '\(minimumRequiredVersion)', but current version '\(BinaryDependenciesManager.version)' is lower." + ) + } + + let dependencies = configuration.dependencies + let dependenciesInfo = dependencies + .map { " \($0.repo)(\($0.tag))" } + .joined(separator: "\n") + Logger.log( + "[Read] Found \(dependencies.count) dependencies:\n\(dependenciesInfo)" + ) + + return configuration + } +} + + +/// A type that defines methods for decoding Data. +/// Similar to the TopLevelDecoder with a restricted input to the Data type. +protocol TopLevelDataDecoder { + func decode(_ type: T.Type, from data: Data) throws -> T where T : Decodable +} + +extension JSONDecoder: TopLevelDataDecoder {} +extension YAMLDecoder: TopLevelDataDecoder {} + diff --git a/Sources/BinaryDependencyManager/Utils/FileManagerProtocol.swift b/Sources/BinaryDependencyManager/Utils/FileManagerProtocol.swift new file mode 100644 index 0000000..b0c8bc7 --- /dev/null +++ b/Sources/BinaryDependencyManager/Utils/FileManagerProtocol.swift @@ -0,0 +1,18 @@ +import Foundation + +protocol FileManagerProtocol { + /// Returns a Boolean value that indicates whether a file or directory exists at a specified path. + /// + /// - Parameter path: The path of the file or directory. If path begins with a tilde (~), it must first be expanded with expandingTildeInPath; otherwise, this method returns false. + /// + /// - Returns: true if a file at the specified path exists, or false if the file does not exist or its existence could not be determined. + func fileExists(atPath path: String) -> Bool + + /// Returns the contents of the file at the specified path. + /// - Parameters: + /// - path: The path of the file whose contents you want. + /// - Returns: An `Data` object with the contents of the file. If path specifies a directory, or if some other error occurs, this method returns nil. + func contents(atPath path: String) -> Data? +} + +extension FileManager: FileManagerProtocol {} diff --git a/Sources/BinaryDependencyManager/Utils/String+fileURL.swift b/Sources/BinaryDependencyManager/Utils/String+fileURL.swift new file mode 100644 index 0000000..22b05de --- /dev/null +++ b/Sources/BinaryDependencyManager/Utils/String+fileURL.swift @@ -0,0 +1,13 @@ +import Foundation + +extension String { + /// Absolute file URL with standardized path. + var asFileURL: URL { + let url = if #available(macOS 13.0, *) { + URL(filePath: self) + } else { + URL(fileURLWithPath: self) + } + return url.standardizedFileURL.absoluteURL + } +} diff --git a/Tests/BinaryDependencyManagerTests/BinaryDependenciesConfigurationReaderTests.swift b/Tests/BinaryDependencyManagerTests/BinaryDependenciesConfigurationReaderTests.swift new file mode 100644 index 0000000..1082d9c --- /dev/null +++ b/Tests/BinaryDependencyManagerTests/BinaryDependenciesConfigurationReaderTests.swift @@ -0,0 +1,82 @@ +@testable import binary_dependencies_manager +import XCTest +import Testing +import Yams +import Foundation + +final class BinaryDependenciesConfigurationReaderTests: XCTestCase { + private func makeReader(withFiles files: [String]) -> BinaryDependenciesConfigurationReader { + let mockFileManager = FileManagerProtocolMock() + mockFileManager.files = Set(files) + return BinaryDependenciesConfigurationReader(fileManager: mockFileManager) + } + + func test_resolveConfigurationFileURL_withExplicitPath() throws { + let sut = makeReader(withFiles: ["/the/path/.binary-dependencies.yaml"]) + let url = try sut.resolveConfigurationFileURL("/the/path/.binary-dependencies.yaml") + XCTAssertEqual(url.path, "/the/path/.binary-dependencies.yaml") + } + + func test_resolveConfigurationFileURL_fallbackToDefault() throws { + let sut = makeReader(withFiles: [".binary-dependencies.yaml".asFileURL.path]) + let url = try sut.resolveConfigurationFileURL(nil) + XCTAssertEqual(url.lastPathComponent, ".binary-dependencies.yaml") + } + + func test_resolveConfigurationFileURL_fileDoesNotExist_throws() { + let sut = makeReader(withFiles: []) + XCTAssertThrowsError(try sut.resolveConfigurationFileURL(nil)) + } + + func test_resolveOutputDirectoryURL_explicit() { + let sut = makeReader(withFiles: []) + let url = sut.resolveOutputDirectoryURL("Explicit/Output") + XCTAssertEqual(url, "Explicit/Output".asFileURL) + } + + func test_resolveCacheDirectoryURL_explicit() { + let sut = makeReader(withFiles: []) + let url = sut.resolveCacheDirectoryURL("Explicit/Cache") + XCTAssertEqual(url, "Explicit/Cache".asFileURL) + } + + func test_readConfiguration_parsesYAML() throws { + // GIVEN + let yamlString = """ + minimumVersion: 0.0.1 + outputDirectory: output/directory + cacheDirectory: cache/directory + dependencies: + - repo: test/repo + tag: "0.0.1" + pattern: pattern1 + checksum: "check1" + """ + let filePath = ".binary-dependencies.yaml".asFileURL.path + let data = Data(yamlString.utf8) + let mockFileManager = FileManagerProtocolMock() + mockFileManager.files = [filePath] + mockFileManager.contents = [filePath: data] + + let sut = BinaryDependenciesConfigurationReader(fileManager: mockFileManager) + + // WHEN + let config = try sut.readConfiguration(at: .none) + + // THEN + let expected = BinaryDependenciesConfiguration( + minimumVersion: Version(string: "0.0.1"), + outputDirectory: "output/directory", + cacheDirectory: "cache/directory", + dependencies: [ + Dependency( + repo: "test/repo", + tag: "0.0.1", + assets: [Dependency.Asset(checksum: "check1", pattern: "pattern1")] + ) + ] + ) + XCTAssertEqual(config, expected) + } +} + diff --git a/Tests/BinaryDependencyManagerTests/Mocks/FileManagerProtocolMock.swift b/Tests/BinaryDependencyManagerTests/Mocks/FileManagerProtocolMock.swift new file mode 100644 index 0000000..cb8ac55 --- /dev/null +++ b/Tests/BinaryDependencyManagerTests/Mocks/FileManagerProtocolMock.swift @@ -0,0 +1,14 @@ +@testable import binary_dependencies_manager +import Foundation + +class FileManagerProtocolMock: FileManagerProtocol { + var files: Set = [] + func fileExists(atPath path: String) -> Bool { + files.contains(path) + } + + var contents: [String: Data] = [:] + func contents(atPath path: String) -> Data? { + contents[path] + } +}