This commit is contained in:
349
Sources/ConfigurationClient/ConfigurationClient.swift
Normal file
349
Sources/ConfigurationClient/ConfigurationClient.swift
Normal file
@@ -0,0 +1,349 @@
|
||||
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.
|
||||
///
|
||||
public var write: @Sendable (WriteOptions) 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(.init(configuration, to: file, force: force))
|
||||
}
|
||||
|
||||
/// Represents the options to generate a configuration file for a user.
|
||||
public struct GenerateOptions: Equatable, Sendable {
|
||||
|
||||
/// Force generation, overwritting existing file if it exists.
|
||||
public let force: Bool
|
||||
|
||||
/// Generate a `json` file instead of the default `toml` file.
|
||||
public let json: Bool
|
||||
|
||||
/// The path to generate a file in.
|
||||
public let path: Path?
|
||||
|
||||
public init(
|
||||
force: Bool = false,
|
||||
json: Bool = false,
|
||||
path: Path? = nil
|
||||
) {
|
||||
self.force = force
|
||||
self.json = json
|
||||
self.path = path
|
||||
}
|
||||
|
||||
/// Represents the path option for generating a configuration file for a user.
|
||||
///
|
||||
/// This can either be a full file path or a directory. If a directory is supplied
|
||||
/// then we will use the default file name of 'config' and add the extension dependning
|
||||
/// on if the caller wants a `json` or `toml` file.
|
||||
public enum Path: Equatable, Sendable {
|
||||
case file(File)
|
||||
case directory(String)
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the options required to write a configuration file.
|
||||
public struct WriteOptions: Equatable, Sendable {
|
||||
|
||||
/// The configuration to wrtie to the file path.
|
||||
public let configuration: Configuration
|
||||
|
||||
/// The file path to write the configuration to.
|
||||
public let file: File
|
||||
|
||||
/// Force overwritting an existing file, if it exists.
|
||||
public let force: Bool
|
||||
|
||||
public init(
|
||||
_ configuration: Configuration,
|
||||
to file: File,
|
||||
force: Bool
|
||||
) {
|
||||
self.configuration = configuration
|
||||
self.file = file
|
||||
self.force = 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(
|
||||
find: { try await liveClient.find() },
|
||||
generate: { try await liveClient.generate($0) },
|
||||
load: { try await liveClient.load(file: $0) },
|
||||
write: { try await liveClient.write($0) }
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
let exists = fileManager.fileExists(fileUrl)
|
||||
|
||||
if !options.force, exists {
|
||||
try await createBackup(file.url)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// TODO: The hpa file needs to be copied somewhere on the system during install and
|
||||
// not use bundle, as it only works if the tool was built on the users system.
|
||||
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(.init(.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(
|
||||
_ options: ConfigurationClient.WriteOptions
|
||||
) async throws {
|
||||
let configuration = options.configuration
|
||||
let file = options.file
|
||||
let force = options.force
|
||||
|
||||
let exists = fileManager.fileExists(file.url)
|
||||
|
||||
if !force, exists {
|
||||
try await createBackup(file.url)
|
||||
}
|
||||
|
||||
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 createBackup(_ url: URL) async throws {
|
||||
let backupUrl = url.appendingPathExtension("back")
|
||||
logger.warning("File exists, creating a backup of the existing file at: \(backupUrl.cleanFilePath)")
|
||||
try await fileManager.copy(url, backupUrl)
|
||||
try await fileManager.delete(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 {}
|
||||
Reference in New Issue
Block a user