Files
swift-hpa/Sources/ConfigurationClient/ConfigurationClient.swift

232 lines
6.3 KiB
Swift

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 {
@Dependency(\.logger) var logger
logger.debug("Begin generating configuration: \(file.path), force: \(force)")
let expandedPath = file.path.replacingOccurrences(
of: "~",
with: fileManager.homeDirectory().cleanFilePath
)
let fileUrl = URL(filePath: expandedPath)
if !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: 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)
}