343 lines
10 KiB
Swift
343 lines
10 KiB
Swift
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)
|
|
|
|
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(.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 {
|
|
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 {}
|