Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<T>(_ type: T.Type, from data: Data) throws -> T where T : Decodable
}

extension JSONDecoder: TopLevelDataDecoder {}
extension YAMLDecoder: TopLevelDataDecoder {}

18 changes: 18 additions & 0 deletions Sources/BinaryDependencyManager/Utils/FileManagerProtocol.swift
Original file line number Diff line number Diff line change
@@ -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 {}
13 changes: 13 additions & 0 deletions Sources/BinaryDependencyManager/Utils/String+fileURL.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Foundation

extension String {
/// Absolute file URL with standardized path.
var asFileURL: URL {
let url = if #available(macOS 13.0, *) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fileURL with starts from about 10.9

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's kinda deprecated, or soon to be deprecated. Along with the path.

image

URL(filePath: self)
} else {
URL(fileURLWithPath: self)
}
return url.standardizedFileURL.absoluteURL
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@testable import binary_dependencies_manager
import Foundation

class FileManagerProtocolMock: FileManagerProtocol {
var files: Set<String> = []
func fileExists(atPath path: String) -> Bool {
files.contains(path)
}

var contents: [String: Data] = [:]
func contents(atPath path: String) -> Data? {
contents[path]
}
}