import CodersClient import Dependencies import DependenciesMacros import FileClient import Foundation import ShellClient public extension DependencyValues { /// Interacts with the user's configuration. var configurationClient: ConfigurationClient { get { self[ConfigurationClient.self] } set { self[ConfigurationClient.self] = newValue } } } /// Represents actions that can be taken on user's configuration files. /// /// @DependencyClient public struct ConfigurationClient: Sendable { /// Find the user's configuration, searches in the current directory and default /// locations where configuration can be stored. An error is thrown if no configuration /// is found. public var find: @Sendable () async throws -> File /// Generate a configuration file for the user. public var generate: @Sendable (GenerateOptions) async throws -> String /// Load a configuration file from the given file location. If the file is /// not provided then we return an empty configuraion item. public var load: @Sendable (File?) async throws -> Configuration /// Write the configuration to the given file, optionally forcing an overwrite of /// the file. /// /// ## NOTE: This uses the `fileClient.write` under the hood, so if you need to control /// what happens during tests, then you can customize the behavior of the fileClient. /// var write: @Sendable (File, Configuration, Bool) async throws -> Void /// Find the user's configuration and load it. public func findAndLoad() async throws -> Configuration { let file = try? await find() return try await load(file) } /// Write the configuration to the given file, optionally forcing an overwrite of /// the file. If a file already exists and force is not supplied then we will create /// a backup of the existing file. /// /// ## NOTE: This uses the `fileClient.write` under the hood, so if you need to control /// what happens during tests, then you can customize the behavior of the fileClient. /// /// - Parameters: /// - configuration: The configuration to save. /// - file: The file location and type to save. /// - force: Force overwritting if a file already exists. 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( find: { try await liveClient.find() }, generate: { try await liveClient.generate($0) }, load: { try await liveClient.load(file: $0) }, write: { try await liveClient.write($1, to: $0, force: $2) } ) } 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 { let exists = fileManager.fileExists(file.url) if !force, exists { let backupUrl = file.url.appendingPathExtension(".back") logger.warning("File exists, creating a backup of the existing file at: \(backupUrl.cleanFilePath)") try await fileManager.copy(file.url, backupUrl) } 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 {}