import CodersClient import Dependencies import DependenciesMacros import FileClient import Foundation import ShellClient public extension DependencyValues { var configurationClient: ConfigurationClient { get { self[ConfigurationClient.self] } set { self[ConfigurationClient.self] = newValue } } } @DependencyClient public struct ConfigurationClient: Sendable { public var find: @Sendable () async throws -> File var generate: @Sendable (File, Bool) async throws -> Void public var load: @Sendable (File?) async throws -> Configuration var write: @Sendable (File, Configuration, Bool) async throws -> Void public func findAndLoad() async throws -> Configuration { let file = try? await find() return try await load(file) } public func generate( at file: File, force: Bool = false ) async throws { try await generate(file, force) } public func write( _ configuration: Configuration, to file: File, force: Bool = false ) async throws { try await write(file, configuration, force) } } extension ConfigurationClient: DependencyKey { public static var testValue: Self { Self() } public static func live(environment: [String: String]) -> Self { let liveClient = LiveConfigurationClient(environment: environment) return .init { try await liveClient.find() } generate: { file, force in try await liveClient.generate(at: file, force: force) } load: { file in try await liveClient.load(file: file) } write: { file, configuration, force in try await liveClient.write(configuration, to: file, force: force) } } public static var liveValue: Self { .live(environment: ProcessInfo.processInfo.environment) } } struct LiveConfigurationClient { private let environment: [String: String] @Dependency(\.coders) var coders @Dependency(\.fileClient) var fileManager @Dependency(\.logger) var logger let validFileNames = [ "config.json", "config.toml", ".hparc.json", ".hparc.toml" ] init(environment: [String: String]) { self.environment = environment } func find() async throws -> File { logger.debug("Begin find configuration.") if let pwd = environment["PWD"], let file = await findInDirectory(pwd) { logger.debug("Found configuration in pwd: \(file.path)") return file } if let configHome = environment.hpaConfigHome, let file = await findInDirectory(configHome) { logger.debug("Found configuration in config home env var: \(file.path)") return file } if let configFile = environment.hpaConfigFile, isReadable(configFile), let file = File(configFile) { logger.debug("Found configuration in config file env var: \(file.path)") return file } if let file = await findInDirectory(fileManager.homeDirectory()) { logger.debug("Found configuration in home directory: \(file.path)") return file } if let file = await findInDirectory("\(environment.xdgConfigHome)/\(HPAKey.configDirName)") { logger.debug("Found configuration in xdg config directory: \(file.path)") return file } throw ConfigurationError.configurationNotFound } func generate(at file: File, force: Bool) async throws { if !force { guard !fileManager.fileExists(file.url) else { throw ConfigurationError.fileExists(path: file.path) } } if case .toml = file { // In the case of toml, we copy the internal resource that includes // usage comments in the file. guard let resourceFile = Bundle.module.url( forResource: HPAKey.resourceFileName, withExtension: HPAKey.resourceFileExtension ) else { throw ConfigurationError.resourceNotFound } try await fileManager.copy(resourceFile, file.url) } else { // Json does not allow comments, so we write the mock configuration // to the file path. try await write(.mock, to: file, force: force) } } func load(file: File?) async throws -> Configuration { guard let file else { return .init() } let data = try await fileManager.load(file.url) switch file { case .json: return try coders.jsonDecoder().decode(Configuration.self, from: data) case .toml: guard let string = String(data: data, encoding: .utf8) else { throw ConfigurationError.decodingError } return try coders.tomlDecoder().decode(Configuration.self, from: string) } } func write( _ configuration: Configuration, to file: File, force: Bool ) async throws { if !force { guard !fileManager.fileExists(file.url) else { throw ConfigurationError.fileExists(path: file.path) } } let data: Data switch file { case .json: data = try coders.jsonEncoder().encode(configuration) case .toml: let string = try coders.tomlEncoder().encode(configuration) data = Data(string.utf8) } try await fileManager.write(data, file.url) } private func findInDirectory(_ directory: URL) async -> File? { for file in validFileNames { let url = directory.appending(path: file) if isReadable(url), let file = File(url) { return file } } return nil } private func findInDirectory(_ directory: String) async -> File? { await findInDirectory(URL(filePath: directory)) } private func isReadable(_ file: URL) -> Bool { FileManager.default.isReadableFile(atPath: file.cleanFilePath) } private func isReadable(_ file: String) -> Bool { isReadable(URL(filePath: file)) } } enum ConfigurationError: Error { case configurationNotFound case resourceNotFound case decodingError case fileExists(path: String) }