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 public var generate: @Sendable (GenerateOptions) async throws -> String 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 write( _ configuration: Configuration, to file: File, force: Bool = false ) async throws { try await write(file, configuration, force) } public struct GenerateOptions: Equatable, Sendable { public let force: Bool public let json: Bool public let path: Path? public init( force: Bool = false, json: Bool = false, path: Path? = nil ) { self.force = force self.json = json self.path = path } public enum Path: Equatable, Sendable { case file(File) case directory(String) } } } 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: { try await liveClient.generate($0) } 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) } @_spi(Internal) public static func mock(_ configuration: Configuration = .mock) -> Self { .init( find: { throw MockFindError() }, generate: Self().generate, load: { _ in configuration }, write: Self().write ) } } 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(_ options: ConfigurationClient.GenerateOptions) async throws -> String { @Dependency(\.logger) var logger let file: File if let path = options.path { switch path { case let .file(requestedFile): file = requestedFile case let .directory(directory): file = .init("\(directory)/\(HPAKey.defaultFileNameWithoutExtension).\(options.json ? "json" : "toml")")! } } else { let configDir = "\(environment.xdgConfigHome)/\(HPAKey.configDirName)" file = .init("\(configDir)/\(HPAKey.defaultFileName)")! } logger.debug("Begin generating configuration: \(file.path), force: \(options.force)") let expandedPath = file.path.replacingOccurrences( of: "~", with: fileManager.homeDirectory().cleanFilePath ) let fileUrl = URL(filePath: expandedPath) if !options.force { guard !fileManager.fileExists(fileUrl) else { throw ConfigurationError.fileExists(path: file.path) } } let fileDirectory = fileUrl.deletingLastPathComponent() let directoryExists = try await fileManager.isDirectory(fileDirectory) if !directoryExists { logger.debug("Creating directory at: \(fileDirectory.cleanFilePath)") try await fileManager.createDirectory(fileDirectory) } 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, fileUrl) } else { // Json does not allow comments, so we write the mock configuration // to the file path. try await write(.mock, to: File(fileUrl)!, force: options.force) } return fileUrl.cleanFilePath } 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) } struct MockFindError: Error {}