feat: Breaking out more dependencies.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ DerivedData/
|
||||
.netrc
|
||||
.nvim/*
|
||||
.swiftpm/*
|
||||
hpa.toml
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// swift-tools-version: 6.0
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
@@ -8,7 +7,9 @@ let package = Package(
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [
|
||||
.executable(name: "hpa", targets: ["hpa"]),
|
||||
.library(name: "CliClient", targets: ["CliClient"])
|
||||
.library(name: "CliClient", targets: ["CliClient"]),
|
||||
.library(name: "CodersClient", targets: ["CodersClient"]),
|
||||
.library(name: "ConfigurationClient", targets: ["ConfigurationClient"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
|
||||
@@ -22,6 +23,7 @@ let package = Package(
|
||||
name: "hpa",
|
||||
dependencies: [
|
||||
"CliClient",
|
||||
"ConfigurationClient",
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "CliDoc", package: "swift-cli-doc"),
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
@@ -31,9 +33,11 @@ let package = Package(
|
||||
.target(
|
||||
name: "CliClient",
|
||||
dependencies: [
|
||||
"CodersClient",
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
.product(name: "ShellClient", package: "swift-shell-client")
|
||||
.product(name: "ShellClient", package: "swift-shell-client"),
|
||||
.product(name: "TOMLKit", package: "TOMLKit")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
@@ -48,6 +52,48 @@ let package = Package(
|
||||
.copy("Resources/vault.yml"),
|
||||
.copy("Resources/hpa-playbook")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "CodersClient",
|
||||
dependencies: [
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
.product(name: "TOMLKit", package: "TOMLKit")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "TestSupport",
|
||||
dependencies: [
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "ShellClient", package: "swift-shell-client")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "ConfigurationClient",
|
||||
dependencies: [
|
||||
"CodersClient",
|
||||
"FileClient",
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
.product(name: "ShellClient", package: "swift-shell-client")
|
||||
],
|
||||
resources: [
|
||||
.copy("Resources/hpa.toml")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "ConfigurationClientTests",
|
||||
dependencies: [
|
||||
"ConfigurationClient",
|
||||
"TestSupport"
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "FileClient",
|
||||
dependencies: [
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -3,8 +3,6 @@ import DependenciesMacros
|
||||
import Foundation
|
||||
import ShellClient
|
||||
|
||||
// TODO: Drop support for non-json configuration.
|
||||
|
||||
public extension DependencyValues {
|
||||
var cliClient: CliClient {
|
||||
get { self[CliClient.self] }
|
||||
@@ -14,11 +12,7 @@ public extension DependencyValues {
|
||||
|
||||
@DependencyClient
|
||||
public struct CliClient: Sendable {
|
||||
public var decoder: @Sendable () -> JSONDecoder = { .init() }
|
||||
public var encoder: @Sendable () -> JSONEncoder = { .init() }
|
||||
public var loadConfiguration: @Sendable () throws -> Configuration
|
||||
public var runCommand: @Sendable ([String], Bool, ShellCommand.Shell) async throws -> Void
|
||||
public var createConfiguration: @Sendable (_ path: String, _ json: Bool) throws -> Void
|
||||
public var findVaultFileInCurrentDirectory: @Sendable () throws -> String
|
||||
|
||||
public func runCommand(
|
||||
@@ -40,30 +34,12 @@ public struct CliClient: Sendable {
|
||||
|
||||
extension CliClient: DependencyKey {
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
public static func live(
|
||||
decoder: JSONDecoder = .init(),
|
||||
encoder: JSONEncoder = .init(),
|
||||
env: [String: String]
|
||||
) -> Self {
|
||||
@Dependency(\.fileClient) var fileClient
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
return .init {
|
||||
decoder
|
||||
} encoder: {
|
||||
encoder
|
||||
} loadConfiguration: {
|
||||
let url = try findConfigurationFiles(env: env)
|
||||
var env = env
|
||||
|
||||
logger.trace("Loading configuration from: \(url)")
|
||||
guard let config = try fileClient.loadFile(url, &env, decoder) else {
|
||||
return try .fromEnv(env)
|
||||
}
|
||||
return config
|
||||
|
||||
} runCommand: { args, quiet, shell in
|
||||
return .init { args, quiet, shell in
|
||||
@Dependency(\.asyncShellClient) var shellClient
|
||||
if !quiet {
|
||||
try await shellClient.foreground(.init(
|
||||
@@ -80,37 +56,15 @@ extension CliClient: DependencyKey {
|
||||
args
|
||||
))
|
||||
}
|
||||
} createConfiguration: { path, json in
|
||||
|
||||
// Early out if a file exists at the path already.
|
||||
guard !fileClient.fileExists(path) else {
|
||||
throw CliClientError.fileExistsAtPath(path)
|
||||
}
|
||||
|
||||
var path = path
|
||||
let data: Data
|
||||
|
||||
if !json {
|
||||
// Write the default env template.
|
||||
data = Data(Configuration.fileTemplate.utf8)
|
||||
} else {
|
||||
if !path.contains(".json") {
|
||||
path += ".json"
|
||||
}
|
||||
data = try jsonEncoder.encode(Configuration.mock)
|
||||
}
|
||||
|
||||
try fileClient.write(path, data)
|
||||
} findVaultFileInCurrentDirectory: {
|
||||
guard let url = try fileClient.findVaultFileInCurrentDirectory() else {
|
||||
throw CliClientError.vaultFileNotFound
|
||||
}
|
||||
return path(for: url)
|
||||
fatalError()
|
||||
// guard let url = try fileClient.findVaultFileInCurrentDirectory() else {
|
||||
// throw CliClientError.vaultFileNotFound
|
||||
// }
|
||||
// return path(for: url)
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable function_body_length
|
||||
|
||||
public static var liveValue: CliClient {
|
||||
.live(env: ProcessInfo.processInfo.environment)
|
||||
}
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import ShellClient
|
||||
|
||||
public struct Configuration2: Codable, Equatable, Sendable {
|
||||
|
||||
public let playbook: Playbook
|
||||
public let template: Template
|
||||
public let vault: Vault
|
||||
|
||||
public init(
|
||||
playbook: Playbook,
|
||||
template: Template,
|
||||
vault: Vault
|
||||
) {
|
||||
self.playbook = playbook
|
||||
self.template = template
|
||||
self.vault = vault
|
||||
}
|
||||
|
||||
public static var mock: Self {
|
||||
.init(playbook: .mock, template: .mock, vault: .mock)
|
||||
}
|
||||
|
||||
public struct Playbook: Codable, Equatable, Sendable {
|
||||
public let directory: String?
|
||||
public let inventory: String?
|
||||
public let args: [String]?
|
||||
public let useVaultArgs: Bool
|
||||
|
||||
public init(
|
||||
directory: String? = nil,
|
||||
inventory: String? = nil,
|
||||
args: [String]? = nil,
|
||||
useVaultArgs: Bool = false
|
||||
) {
|
||||
self.directory = directory
|
||||
self.inventory = inventory
|
||||
self.args = args
|
||||
self.useVaultArgs = useVaultArgs
|
||||
}
|
||||
|
||||
public static var mock: Self {
|
||||
.init(
|
||||
directory: "/path/to/local/playbook-directory",
|
||||
inventory: "/path/to/local/inventory.ini",
|
||||
args: [],
|
||||
useVaultArgs: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct Template: Codable, Equatable, Sendable {
|
||||
let url: String?
|
||||
let version: String?
|
||||
let directory: String?
|
||||
|
||||
public init(
|
||||
url: String? = nil,
|
||||
version: String? = nil,
|
||||
directory: String? = nil
|
||||
) {
|
||||
self.url = url
|
||||
self.version = version
|
||||
self.directory = directory
|
||||
}
|
||||
|
||||
public static var mock: Self {
|
||||
.init(
|
||||
url: "https://git.example.com/consult-template.git",
|
||||
version: "main",
|
||||
directory: "/path/to/local/template-directory"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct Vault: Codable, Equatable, Sendable {
|
||||
public let args: [String]?
|
||||
public let encryptId: String?
|
||||
|
||||
public init(
|
||||
args: [String]? = nil,
|
||||
encryptId: String? = nil
|
||||
) {
|
||||
self.args = args
|
||||
self.encryptId = encryptId
|
||||
}
|
||||
|
||||
public static var mock: Self {
|
||||
.init(
|
||||
args: [
|
||||
"--vault-id=myId@$SCRIPTS/vault-gopass-client"
|
||||
],
|
||||
encryptId: "myId"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the configurable items for the command line tool.
|
||||
///
|
||||
///
|
||||
public struct Configuration: Codable, Equatable, Sendable {
|
||||
|
||||
public let playbookDir: String?
|
||||
public let inventoryPath: String?
|
||||
public let templateRepo: String?
|
||||
public let templateRepoVersion: String?
|
||||
public let templateDir: String?
|
||||
public let defaultPlaybookArgs: [String]?
|
||||
public let defaultVaultArgs: [String]?
|
||||
public let defaultVaultEncryptId: String?
|
||||
|
||||
fileprivate enum CodingKeys: String, CodingKey {
|
||||
case playbookDir = "HPA_PLAYBOOK_DIR"
|
||||
case inventoryPath = "HPA_DEFAULT_INVENTORY"
|
||||
case templateRepo = "HPA_TEMPLATE_REPO"
|
||||
case templateRepoVersion = "HPA_TEMPLATE_VERSION"
|
||||
case templateDir = "HPA_TEMPLATE_DIR"
|
||||
case defaultPlaybookArgs = "HPA_DEFAULT_PLAYBOOK_ARGS"
|
||||
case defaultVaultArgs = "HPA_DEFAULT_VAULT_ARGS"
|
||||
case defaultVaultEncryptId = "HPA_DEFAULT_VAULT_ENCRYPT_ID"
|
||||
}
|
||||
|
||||
public static func fromEnv(
|
||||
_ env: [String: String]
|
||||
) throws -> Self {
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
logger.trace("Creating configuration from env...")
|
||||
|
||||
let hpaValues: [String: String] = env.filter { $0.key.contains("HPA") }
|
||||
logger.debug("HPA env vars: \(hpaValues)")
|
||||
|
||||
return Configuration(
|
||||
playbookDir: hpaValues.value(for: .playbookDir),
|
||||
inventoryPath: hpaValues.value(for: .inventoryPath),
|
||||
templateRepo: hpaValues.value(for: .templateRepo),
|
||||
templateRepoVersion: hpaValues.value(for: .templateRepoVersion),
|
||||
templateDir: hpaValues.value(for: .templateDir),
|
||||
defaultPlaybookArgs: hpaValues.array(for: .defaultPlaybookArgs),
|
||||
defaultVaultArgs: hpaValues.array(for: .defaultVaultArgs),
|
||||
defaultVaultEncryptId: hpaValues.value(for: .defaultVaultEncryptId)
|
||||
)
|
||||
}
|
||||
|
||||
static var mock: Self {
|
||||
.init(
|
||||
playbookDir: "/path/to/playbook",
|
||||
inventoryPath: "/path/to/inventory.ini",
|
||||
templateRepo: "https://git.example.com/consult-template.git",
|
||||
templateRepoVersion: "main",
|
||||
templateDir: "/path/to/local/template",
|
||||
defaultPlaybookArgs: ["--tags", "debug"],
|
||||
defaultVaultArgs: ["--vault-id=myId@$SCRIPTS/vault-gopass-client"],
|
||||
defaultVaultEncryptId: "myId"
|
||||
)
|
||||
}
|
||||
|
||||
static var fileTemplate: String {
|
||||
"""
|
||||
# vi: ft=sh
|
||||
|
||||
# Example configuration, uncomment the lines and set the values appropriate for your
|
||||
# usage.
|
||||
|
||||
# Set this to the location of the ansible-hpa-playbook on your local machine.
|
||||
#HPA_PLAYBOOK_DIR="/path/to/ansible-hpa-playbook"
|
||||
|
||||
# Set this to the location of a template repository, which is used to create new assessment projects.
|
||||
#HPA_TEMPLATE_REPO="https://git.example.com/your/template.git"
|
||||
|
||||
# Specify a branch, version, or sha of the template repository.
|
||||
#HPA_TEMPLATE_VERSION="main" # branch, version, or sha
|
||||
|
||||
# Set this to a location of a template directory to use to create new projects.
|
||||
#HPA_TEMPLATE_DIR="/path/to/local/template"
|
||||
|
||||
# Extra arguments that get passed directly to the ansible-playbook command.
|
||||
#HPA_DEFAULT_PLAYBOOK_ARGS="--vault-id=consults@$SCRIPTS/vault-gopass-client"
|
||||
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
extension [String: String] {
|
||||
fileprivate func value(for codingKey: Configuration.CodingKeys) -> String? {
|
||||
self[codingKey.rawValue]
|
||||
}
|
||||
|
||||
fileprivate func array(for codingKey: Configuration.CodingKeys) -> [String]? {
|
||||
value(for: codingKey).flatMap { $0.split(separator: ",").map(String.init) }
|
||||
}
|
||||
}
|
||||
13
Sources/CliClient/Constants.swift
Normal file
13
Sources/CliClient/Constants.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
/// Holds keys associated with environment values.
|
||||
public enum EnvironmentKey {
|
||||
public static let xdgConfigHomeKey = "XDG_CONFIG_HOME"
|
||||
public static let hpaConfigDirKey = "HPA_CONFIG_DIR"
|
||||
public static let hpaConfigFileKey = "HPA_CONFIG_FILE"
|
||||
}
|
||||
|
||||
/// Holds keys associated with hpa configuration files.
|
||||
public enum HPAKey {
|
||||
public static let defaultConfigHome = "~/.config/\(Self.hpaConfigDirectoryName)/\(Self.defaultConfigFileName)"
|
||||
public static let defaultConfigFileName = "config.toml"
|
||||
public static let hpaConfigDirectoryName = "hpa"
|
||||
}
|
||||
@@ -1,159 +1,183 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
|
||||
@_spi(Internal)
|
||||
public extension DependencyValues {
|
||||
var fileClient: FileClient {
|
||||
get { self[FileClient.self] }
|
||||
set { self[FileClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(Internal)
|
||||
@DependencyClient
|
||||
public struct FileClient: Sendable {
|
||||
/// Loads a file at the given path into the environment unless it can decode it as json,
|
||||
/// at which point it will return the decoded file contents as a ``Configuration`` item.
|
||||
public var loadFile: @Sendable (URL, inout [String: String], JSONDecoder) throws -> Configuration?
|
||||
|
||||
/// Returns the user's home directory path.
|
||||
public var homeDir: @Sendable () -> URL = { URL(string: "~/")! }
|
||||
|
||||
/// Check if a path is a directory.
|
||||
public var isDirectory: @Sendable (URL) -> Bool = { _ in false }
|
||||
|
||||
/// Check if a path is a readable.
|
||||
public var isReadable: @Sendable (URL) -> Bool = { _ in false }
|
||||
|
||||
/// Check if a file exists at the path.
|
||||
public var fileExists: @Sendable (String) -> Bool = { _ in false }
|
||||
|
||||
public var findVaultFile: @Sendable (String) throws -> URL?
|
||||
|
||||
/// Write data to a file.
|
||||
public var write: @Sendable (String, Data) throws -> Void
|
||||
|
||||
public func findVaultFileInCurrentDirectory() throws -> URL? {
|
||||
try findVaultFile(".")
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(Internal)
|
||||
extension FileClient: DependencyKey {
|
||||
public static let testValue: FileClient = Self()
|
||||
|
||||
public static func live(fileManager: FileManager = .default) -> Self {
|
||||
let client = LiveFileClient(fileManager: fileManager)
|
||||
return Self(
|
||||
loadFile: { try client.loadFile(at: $0, into: &$1, decoder: $2) },
|
||||
homeDir: { client.homeDir },
|
||||
isDirectory: { client.isDirectory(url: $0) },
|
||||
isReadable: { client.isReadable(url: $0) },
|
||||
fileExists: { client.fileExists(at: $0) },
|
||||
findVaultFile: { try client.findVaultFile(in: $0) },
|
||||
write: { path, data in
|
||||
try data.write(to: URL(filePath: path))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public static let liveValue = Self.live()
|
||||
}
|
||||
|
||||
private struct LiveFileClient: @unchecked Sendable {
|
||||
|
||||
private let fileManager: FileManager
|
||||
|
||||
init(fileManager: FileManager) {
|
||||
self.fileManager = fileManager
|
||||
}
|
||||
|
||||
var homeDir: URL { fileManager.homeDirectoryForCurrentUser }
|
||||
|
||||
func isDirectory(url: URL) -> Bool {
|
||||
var isDirectory: ObjCBool = false
|
||||
fileManager.fileExists(atPath: path(for: url), isDirectory: &isDirectory)
|
||||
return isDirectory.boolValue
|
||||
}
|
||||
|
||||
func isReadable(url: URL) -> Bool {
|
||||
fileManager.isReadableFile(atPath: path(for: url))
|
||||
}
|
||||
|
||||
func fileExists(at path: String) -> Bool {
|
||||
fileManager.fileExists(atPath: path)
|
||||
}
|
||||
|
||||
// func findVaultFileInCurrentDirectory() throws -> URL? {
|
||||
|
||||
func findVaultFile(in filePath: String) throws -> URL? {
|
||||
let urls = try fileManager
|
||||
.contentsOfDirectory(at: URL(filePath: filePath), includingPropertiesForKeys: nil)
|
||||
|
||||
if let vault = urls.firstVaultFile {
|
||||
return vault
|
||||
}
|
||||
|
||||
// check for folders that end with "vars" and search those next.
|
||||
for folder in urls.filter({ $0.absoluteString.hasSuffix("vars/") }) {
|
||||
let files = try fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)
|
||||
if let vault = files.firstVaultFile {
|
||||
return vault
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to check all sub-folders
|
||||
for folder in urls.filter({ self.isDirectory(url: $0) && !$0.absoluteString.hasSuffix("vars/") }) {
|
||||
let files = try fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)
|
||||
if let vault = files.firstVaultFile {
|
||||
return vault
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadFile(
|
||||
at url: URL,
|
||||
into env: inout [String: String],
|
||||
decoder: JSONDecoder
|
||||
) throws -> Configuration? {
|
||||
@Dependency(\.logger) var logger
|
||||
logger.trace("Begin load file for: \(path(for: url))")
|
||||
|
||||
if url.absoluteString.hasSuffix(".json") {
|
||||
// Handle json file.
|
||||
let data = try Data(contentsOf: url)
|
||||
return try decoder.decode(Configuration.self, from: data)
|
||||
}
|
||||
|
||||
let string = try String(contentsOfFile: path(for: url), encoding: .utf8)
|
||||
|
||||
logger.trace("Loaded file contents: \(string)")
|
||||
|
||||
let lines = string.split(separator: "\n")
|
||||
for line in lines {
|
||||
logger.trace("Line: \(line)")
|
||||
let strippedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let splitLine = strippedLine.split(separator: "=").map {
|
||||
$0.replacingOccurrences(of: "\"", with: "")
|
||||
}
|
||||
logger.trace("Split Line: \(splitLine)")
|
||||
guard splitLine.count >= 2 else { continue }
|
||||
|
||||
if splitLine.count > 2 {
|
||||
let rest = splitLine.dropFirst()
|
||||
env[String(splitLine[0])] = String(rest.joined(separator: "="))
|
||||
} else {
|
||||
env[String(splitLine[0])] = String(splitLine[1])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array where Element == URL {
|
||||
var firstVaultFile: URL? {
|
||||
first { $0.absoluteString.hasSuffix("vault.yml") }
|
||||
}
|
||||
}
|
||||
// import CodersClient
|
||||
// import Dependencies
|
||||
// import DependenciesMacros
|
||||
// import Foundation
|
||||
// import TOMLKit
|
||||
//
|
||||
// @_spi(Internal)
|
||||
// public extension DependencyValues {
|
||||
// var fileClient: FileClient {
|
||||
// get { self[FileClient.self] }
|
||||
// set { self[FileClient.self] = newValue }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// @_spi(Internal)
|
||||
// @DependencyClient
|
||||
// public struct FileClient: Sendable {
|
||||
//
|
||||
// /// Load a file as `Data`.
|
||||
// public var loadFile: @Sendable (URL) throws -> Data
|
||||
//
|
||||
// /// Returns the user's home directory path.
|
||||
// public var homeDir: @Sendable () -> URL = { URL(string: "~/")! }
|
||||
//
|
||||
// /// Check if a path is a directory.
|
||||
// public var isDirectory: @Sendable (URL) -> Bool = { _ in false }
|
||||
//
|
||||
// /// Check if a path is a readable.
|
||||
// public var isReadable: @Sendable (URL) -> Bool = { _ in false }
|
||||
//
|
||||
// /// Check if a file exists at the path.
|
||||
// public var fileExists: @Sendable (String) -> Bool = { _ in false }
|
||||
//
|
||||
// public var findVaultFile: @Sendable (String) throws -> URL?
|
||||
//
|
||||
// /// Write data to a file.
|
||||
// public var write: @Sendable (File, Data) throws -> Void
|
||||
//
|
||||
// public func findVaultFileInCurrentDirectory() throws -> URL? {
|
||||
// try findVaultFile(".")
|
||||
// }
|
||||
//
|
||||
// public func loadFile(_ file: File) throws -> Data {
|
||||
// try loadFile(file.url)
|
||||
// }
|
||||
//
|
||||
// public func loadConfiguration(
|
||||
// _ file: File
|
||||
// ) throws -> Configuration {
|
||||
// @Dependency(\.coders) var coders
|
||||
//
|
||||
// let data = try loadFile(file)
|
||||
//
|
||||
// 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 DecodingError()
|
||||
// }
|
||||
// return try coders.tomlDecoder().decode(Configuration.self, from: string)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// @_spi(Internal)
|
||||
// extension FileClient: DependencyKey {
|
||||
// public static let testValue: FileClient = Self()
|
||||
//
|
||||
// public static func live(fileManager: FileManager = .default) -> Self {
|
||||
// let client = LiveFileClient(fileManager: fileManager)
|
||||
// return Self(
|
||||
// loadFile: { try Data(contentsOf: $0) },
|
||||
// homeDir: { client.homeDir },
|
||||
// isDirectory: { client.isDirectory(url: $0) },
|
||||
// isReadable: { client.isReadable(url: $0) },
|
||||
// fileExists: { client.fileExists(at: $0) },
|
||||
// findVaultFile: { try client.findVaultFile(in: $0) },
|
||||
// write: { file, data in
|
||||
// try data.write(to: file.url)
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// public static let liveValue = Self.live()
|
||||
// }
|
||||
//
|
||||
// private struct LiveFileClient: @unchecked Sendable {
|
||||
//
|
||||
// private let fileManager: FileManager
|
||||
//
|
||||
// init(fileManager: FileManager) {
|
||||
// self.fileManager = fileManager
|
||||
// }
|
||||
//
|
||||
// var homeDir: URL { fileManager.homeDirectoryForCurrentUser }
|
||||
//
|
||||
// func isDirectory(url: URL) -> Bool {
|
||||
// var isDirectory: ObjCBool = false
|
||||
// fileManager.fileExists(atPath: path(for: url), isDirectory: &isDirectory)
|
||||
// return isDirectory.boolValue
|
||||
// }
|
||||
//
|
||||
// func isReadable(url: URL) -> Bool {
|
||||
// fileManager.isReadableFile(atPath: path(for: url))
|
||||
// }
|
||||
//
|
||||
// func fileExists(at path: String) -> Bool {
|
||||
// fileManager.fileExists(atPath: path)
|
||||
// }
|
||||
//
|
||||
// func findVaultFile(in filePath: String) throws -> URL? {
|
||||
// let urls = try fileManager
|
||||
// .contentsOfDirectory(at: URL(filePath: filePath), includingPropertiesForKeys: nil)
|
||||
//
|
||||
// if let vault = urls.firstVaultFile {
|
||||
// return vault
|
||||
// }
|
||||
//
|
||||
// // check for folders that end with "vars" and search those next.
|
||||
// for folder in urls.filter({ $0.absoluteString.hasSuffix("vars/") }) {
|
||||
// let files = try fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)
|
||||
// if let vault = files.firstVaultFile {
|
||||
// return vault
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Fallback to check all sub-folders
|
||||
// for folder in urls.filter({ self.isDirectory(url: $0) && !$0.absoluteString.hasSuffix("vars/") }) {
|
||||
// let files = try fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)
|
||||
// if let vault = files.firstVaultFile {
|
||||
// return vault
|
||||
// }
|
||||
// }
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// func loadFile(
|
||||
// at url: URL,
|
||||
// into env: inout [String: String],
|
||||
// decoder: JSONDecoder
|
||||
// ) throws -> Configuration? {
|
||||
// @Dependency(\.logger) var logger
|
||||
// logger.trace("Begin load file for: \(path(for: url))")
|
||||
//
|
||||
// if url.absoluteString.hasSuffix(".json") {
|
||||
// // Handle json file.
|
||||
// let data = try Data(contentsOf: url)
|
||||
// return try decoder.decode(Configuration.self, from: data)
|
||||
// }
|
||||
//
|
||||
// let string = try String(contentsOfFile: path(for: url), encoding: .utf8)
|
||||
//
|
||||
// logger.trace("Loaded file contents: \(string)")
|
||||
//
|
||||
// let lines = string.split(separator: "\n")
|
||||
// for line in lines {
|
||||
// logger.trace("Line: \(line)")
|
||||
// let strippedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// let splitLine = strippedLine.split(separator: "=").map {
|
||||
// $0.replacingOccurrences(of: "\"", with: "")
|
||||
// }
|
||||
// logger.trace("Split Line: \(splitLine)")
|
||||
// guard splitLine.count >= 2 else { continue }
|
||||
//
|
||||
// if splitLine.count > 2 {
|
||||
// let rest = splitLine.dropFirst()
|
||||
// env[String(splitLine[0])] = String(rest.joined(separator: "="))
|
||||
// } else {
|
||||
// env[String(splitLine[0])] = String(splitLine[1])
|
||||
// }
|
||||
// }
|
||||
// return nil
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// struct DecodingError: Error {}
|
||||
//
|
||||
// private extension Array where Element == URL {
|
||||
// var firstVaultFile: URL? {
|
||||
// first { $0.absoluteString.hasSuffix("vault.yml") }
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -1,110 +1,116 @@
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import ShellClient
|
||||
|
||||
@_spi(Internal)
|
||||
public func findConfigurationFiles(
|
||||
env: [String: String] = ProcessInfo.processInfo.environment
|
||||
) throws -> URL {
|
||||
@Dependency(\.logger) var logger
|
||||
@Dependency(\.fileClient) var fileClient
|
||||
|
||||
logger.debug("Begin find configuration files.")
|
||||
logger.trace("Env: \(env)")
|
||||
|
||||
// Check for environment variable pointing to a directory that the
|
||||
// the configuration lives.
|
||||
if let pwd = env["PWD"],
|
||||
let url = fileClient.checkUrl(.file(pwd, ".hparc"))
|
||||
{
|
||||
logger.debug("Found configuration in current working directory.")
|
||||
return url
|
||||
}
|
||||
|
||||
// Check for environment variable pointing to a file that the
|
||||
// the configuration lives.
|
||||
if let configFile = env["HPA_CONFIG_FILE"],
|
||||
let url = fileClient.checkUrl(.file(configFile))
|
||||
{
|
||||
logger.debug("Found configuration from hpa config file env var.")
|
||||
return url
|
||||
}
|
||||
|
||||
// Check for environment variable pointing to a directory that the
|
||||
// the configuration lives.
|
||||
if let configHome = env["HPA_CONFIG_HOME"],
|
||||
let url = fileClient.checkUrl(.directory(configHome))
|
||||
{
|
||||
logger.debug("Found configuration from hpa config home env var.")
|
||||
return url
|
||||
}
|
||||
|
||||
// Check home directory for a `.hparc` file.
|
||||
if let url = fileClient.checkUrl(.file(fileClient.homeDir().appending(path: ".hparc"))) {
|
||||
logger.debug("Found configuration in home directory")
|
||||
return url
|
||||
}
|
||||
|
||||
// Check in xdg config home, under an hpa-playbook directory.
|
||||
if let xdgConfigHome = env["XDG_CONFIG_HOME"],
|
||||
let url = fileClient.checkUrl(.directory(xdgConfigHome, "hpa-playbook"))
|
||||
{
|
||||
logger.debug("XDG Config url: \(url.absoluteString)")
|
||||
return url
|
||||
}
|
||||
|
||||
// We could not find configuration in any usual places.
|
||||
throw ConfigurationError.configurationNotFound
|
||||
}
|
||||
|
||||
@_spi(Internal)
|
||||
public func path(for url: URL) -> String {
|
||||
url.absoluteString.replacing("file://", with: "")
|
||||
}
|
||||
|
||||
enum ConfigurationError: Error {
|
||||
case configurationNotFound
|
||||
}
|
||||
|
||||
private extension FileClient {
|
||||
|
||||
enum ConfigurationUrlCheck {
|
||||
case file(URL)
|
||||
case directory(URL)
|
||||
|
||||
static func file(_ path: String) -> Self { .file(URL(filePath: path)) }
|
||||
|
||||
static func file(_ paths: String...) -> Self {
|
||||
var url = URL(filePath: paths[0])
|
||||
url = paths.dropFirst().reduce(into: url) { $0.append(path: $1) }
|
||||
return .file(url)
|
||||
}
|
||||
|
||||
static func directory(_ path: String) -> Self { .directory(URL(filePath: path)) }
|
||||
static func directory(_ paths: String...) -> Self {
|
||||
var url = URL(filePath: paths[0])
|
||||
url = paths.dropFirst().reduce(into: url) { $0.append(path: $1) }
|
||||
return .directory(url)
|
||||
}
|
||||
}
|
||||
|
||||
func checkUrl(_ check: ConfigurationUrlCheck) -> URL? {
|
||||
switch check {
|
||||
case let .file(url):
|
||||
if isReadable(url) { return url }
|
||||
return nil
|
||||
case let .directory(url):
|
||||
return findConfigurationInDirectory(url)
|
||||
}
|
||||
}
|
||||
|
||||
func findConfigurationInDirectory(_ url: URL) -> URL? {
|
||||
for file in ["config", "config.json"] {
|
||||
let fileUrl = url.appending(path: file)
|
||||
if isReadable(fileUrl) {
|
||||
return fileUrl
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// import Dependencies
|
||||
// import Foundation
|
||||
// import ShellClient
|
||||
//
|
||||
// @_spi(Internal)
|
||||
// public func findConfigurationFiles(
|
||||
// env: [String: String] = ProcessInfo.processInfo.environment
|
||||
// ) throws -> File {
|
||||
// @Dependency(\.logger) var logger
|
||||
// @Dependency(\.fileClient) var fileClient
|
||||
//
|
||||
// logger.debug("Begin find configuration files.")
|
||||
// logger.trace("Env: \(env)")
|
||||
//
|
||||
// // Check for environment variable pointing to a directory that the
|
||||
// // the configuration lives.
|
||||
// if let pwd = env["PWD"],
|
||||
// let file = fileClient.checkUrl(.directory(pwd))
|
||||
// {
|
||||
// logger.debug("Found configuration in current working directory.")
|
||||
// return file
|
||||
// }
|
||||
//
|
||||
// // Check for environment variable pointing to a file that the
|
||||
// // the configuration lives.
|
||||
// if let configFile = env[EnvironmentKey.hpaConfigFileKey],
|
||||
// let file = fileClient.checkUrl(.file(configFile))
|
||||
// {
|
||||
// logger.debug("Found configuration from hpa config file env var.")
|
||||
// return file
|
||||
// }
|
||||
//
|
||||
// // Check for environment variable pointing to a directory that the
|
||||
// // the configuration lives.
|
||||
// if let configHome = env[EnvironmentKey.hpaConfigDirKey],
|
||||
// let file = fileClient.checkUrl(.directory(configHome))
|
||||
// {
|
||||
// logger.debug("Found configuration from hpa config home env var.")
|
||||
// return file
|
||||
// }
|
||||
//
|
||||
// // Check home directory for a `.hparc` file.
|
||||
// if let file = fileClient.checkUrl(.directory(fileClient.homeDir())) {
|
||||
// logger.debug("Found configuration in home directory")
|
||||
// return file
|
||||
// }
|
||||
//
|
||||
// // Check in xdg config home, under an hpa-playbook directory.
|
||||
// if let xdgConfigHome = env[EnvironmentKey.xdgConfigHomeKey],
|
||||
// let file = fileClient.checkUrl(.directory(xdgConfigHome, HPAKey.hpaConfigDirectoryName))
|
||||
// {
|
||||
// logger.debug("XDG Config url: \(file.path)")
|
||||
// return file
|
||||
// }
|
||||
//
|
||||
// // We could not find configuration in any usual places.
|
||||
// throw ConfigurationError.configurationNotFound
|
||||
// }
|
||||
//
|
||||
// @_spi(Internal)
|
||||
// public func path(for url: URL) -> String {
|
||||
// url.absoluteString.replacing("file://", with: "")
|
||||
// }
|
||||
//
|
||||
// enum ConfigurationError: Error {
|
||||
// case configurationNotFound
|
||||
// }
|
||||
//
|
||||
// private extension FileClient {
|
||||
//
|
||||
// static let validFileNames = [
|
||||
// "config.json", "config.toml",
|
||||
// ".hparc.json", ".hparc.toml"
|
||||
// ]
|
||||
//
|
||||
// enum ConfigurationUrlCheck {
|
||||
// case file(URL)
|
||||
// case directory(URL)
|
||||
//
|
||||
// static func file(_ path: String) -> Self { .file(URL(filePath: path)) }
|
||||
//
|
||||
// static func file(_ paths: String...) -> Self {
|
||||
// var url = URL(filePath: paths[0])
|
||||
// url = paths.dropFirst().reduce(into: url) { $0.append(path: $1) }
|
||||
// return .file(url)
|
||||
// }
|
||||
//
|
||||
// static func directory(_ path: String) -> Self { .directory(URL(filePath: path)) }
|
||||
//
|
||||
// static func directory(_ paths: String...) -> Self {
|
||||
// var url = URL(filePath: paths[0])
|
||||
// url = paths.dropFirst().reduce(into: url) { $0.append(path: $1) }
|
||||
// return .directory(url)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func checkUrl(_ check: ConfigurationUrlCheck) -> File? {
|
||||
// switch check {
|
||||
// case let .file(url):
|
||||
// if isReadable(url) { return .init(url) }
|
||||
// return nil
|
||||
// case let .directory(url):
|
||||
// return findConfigurationInDirectory(url)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func findConfigurationInDirectory(_ url: URL) -> File? {
|
||||
// for file in Self.validFileNames {
|
||||
// let fileUrl = url.appending(path: file)
|
||||
// if isReadable(fileUrl) {
|
||||
// return .init(fileUrl)
|
||||
// }
|
||||
// }
|
||||
// return nil
|
||||
// }
|
||||
// }
|
||||
|
||||
33
Sources/CodersClient/Coders.swift
Normal file
33
Sources/CodersClient/Coders.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
import TOMLKit
|
||||
|
||||
public extension DependencyValues {
|
||||
var coders: Coders {
|
||||
get { self[Coders.self] }
|
||||
set { self[Coders.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
public struct Coders: Sendable {
|
||||
public var jsonDecoder: @Sendable () -> JSONDecoder = { .init() }
|
||||
public var jsonEncoder: @Sendable () -> JSONEncoder = { .init() }
|
||||
public var tomlDecoder: @Sendable () -> TOMLDecoder = { .init() }
|
||||
public var tomlEncoder: @Sendable () -> TOMLEncoder = { .init() }
|
||||
}
|
||||
|
||||
extension Coders: DependencyKey {
|
||||
|
||||
public static var testValue: Self { Self() }
|
||||
|
||||
public static var liveValue: Self {
|
||||
.init(
|
||||
jsonDecoder: { JSONDecoder() },
|
||||
jsonEncoder: { JSONEncoder() },
|
||||
tomlDecoder: { TOMLDecoder() },
|
||||
tomlEncoder: { TOMLEncoder() }
|
||||
)
|
||||
}
|
||||
}
|
||||
188
Sources/ConfigurationClient/Configuration.swift
Normal file
188
Sources/ConfigurationClient/Configuration.swift
Normal file
@@ -0,0 +1,188 @@
|
||||
import Foundation
|
||||
|
||||
/// Represents configurable settings for the command line tool.
|
||||
public struct Configuration: Codable, Equatable, Sendable {
|
||||
public let args: [String]?
|
||||
public let useVaultArgs: Bool
|
||||
public let playbook: Playbook?
|
||||
public let template: Template
|
||||
public let vault: Vault
|
||||
|
||||
public init(
|
||||
args: [String]? = nil,
|
||||
useVaultArgs: Bool = true,
|
||||
playbook: Playbook? = nil,
|
||||
template: Template = .init(),
|
||||
vault: Vault = .init()
|
||||
) {
|
||||
self.args = args
|
||||
self.useVaultArgs = useVaultArgs
|
||||
self.playbook = playbook
|
||||
self.template = template
|
||||
self.vault = vault
|
||||
}
|
||||
|
||||
public static var mock: Self {
|
||||
.init(
|
||||
args: [],
|
||||
useVaultArgs: true,
|
||||
playbook: nil,
|
||||
template: .mock,
|
||||
vault: .mock
|
||||
)
|
||||
}
|
||||
|
||||
public struct Playbook: Codable, Equatable, Sendable {
|
||||
public let directory: String?
|
||||
public let inventory: String?
|
||||
|
||||
public init(
|
||||
directory: String? = nil,
|
||||
inventory: String? = nil
|
||||
) {
|
||||
self.directory = directory
|
||||
self.inventory = inventory
|
||||
}
|
||||
|
||||
public static var mock: Self { .init() }
|
||||
}
|
||||
|
||||
public struct Template: Codable, Equatable, Sendable {
|
||||
public let url: String?
|
||||
public let version: String?
|
||||
public let directory: String?
|
||||
|
||||
public init(
|
||||
url: String? = nil,
|
||||
version: String? = nil,
|
||||
directory: String? = nil
|
||||
) {
|
||||
self.url = url
|
||||
self.version = version
|
||||
self.directory = directory
|
||||
}
|
||||
|
||||
public static var mock: Self {
|
||||
.init(
|
||||
url: "https://git.example.com/consult-template.git",
|
||||
version: "1.0.0",
|
||||
directory: "/path/to/local/template-directory"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct Vault: Codable, Equatable, Sendable {
|
||||
public let args: [String]?
|
||||
public let encryptId: String?
|
||||
|
||||
public init(
|
||||
args: [String]? = nil,
|
||||
encryptId: String? = nil
|
||||
) {
|
||||
self.args = args
|
||||
self.encryptId = encryptId
|
||||
}
|
||||
|
||||
public static var mock: Self {
|
||||
.init(
|
||||
args: [
|
||||
"--vault-id=myId@$SCRIPTS/vault-gopass-client"
|
||||
],
|
||||
encryptId: "myId"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// public struct Configuration: Codable, Equatable, Sendable {
|
||||
//
|
||||
// public let playbookDir: String?
|
||||
// public let inventoryPath: String?
|
||||
// public let templateRepo: String?
|
||||
// public let templateRepoVersion: String?
|
||||
// public let templateDir: String?
|
||||
// public let defaultPlaybookArgs: [String]?
|
||||
// public let defaultVaultArgs: [String]?
|
||||
// public let defaultVaultEncryptId: String?
|
||||
//
|
||||
// fileprivate enum CodingKeys: String, CodingKey {
|
||||
// case playbookDir = "HPA_PLAYBOOK_DIR"
|
||||
// case inventoryPath = "HPA_DEFAULT_INVENTORY"
|
||||
// case templateRepo = "HPA_TEMPLATE_REPO"
|
||||
// case templateRepoVersion = "HPA_TEMPLATE_VERSION"
|
||||
// case templateDir = "HPA_TEMPLATE_DIR"
|
||||
// case defaultPlaybookArgs = "HPA_DEFAULT_PLAYBOOK_ARGS"
|
||||
// case defaultVaultArgs = "HPA_DEFAULT_VAULT_ARGS"
|
||||
// case defaultVaultEncryptId = "HPA_DEFAULT_VAULT_ENCRYPT_ID"
|
||||
// }
|
||||
//
|
||||
// public static func fromEnv(
|
||||
// _ env: [String: String]
|
||||
// ) throws -> Self {
|
||||
// @Dependency(\.logger) var logger
|
||||
//
|
||||
// logger.trace("Creating configuration from env...")
|
||||
//
|
||||
// let hpaValues: [String: String] = env.filter { $0.key.contains("HPA") }
|
||||
// logger.debug("HPA env vars: \(hpaValues)")
|
||||
//
|
||||
// return Configuration(
|
||||
// playbookDir: hpaValues.value(for: .playbookDir),
|
||||
// inventoryPath: hpaValues.value(for: .inventoryPath),
|
||||
// templateRepo: hpaValues.value(for: .templateRepo),
|
||||
// templateRepoVersion: hpaValues.value(for: .templateRepoVersion),
|
||||
// templateDir: hpaValues.value(for: .templateDir),
|
||||
// defaultPlaybookArgs: hpaValues.array(for: .defaultPlaybookArgs),
|
||||
// defaultVaultArgs: hpaValues.array(for: .defaultVaultArgs),
|
||||
// defaultVaultEncryptId: hpaValues.value(for: .defaultVaultEncryptId)
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// static var mock: Self {
|
||||
// .init(
|
||||
// playbookDir: "/path/to/playbook",
|
||||
// inventoryPath: "/path/to/inventory.ini",
|
||||
// templateRepo: "https://git.example.com/consult-template.git",
|
||||
// templateRepoVersion: "main",
|
||||
// templateDir: "/path/to/local/template",
|
||||
// defaultPlaybookArgs: ["--tags", "debug"],
|
||||
// defaultVaultArgs: ["--vault-id=myId@$SCRIPTS/vault-gopass-client"],
|
||||
// defaultVaultEncryptId: "myId"
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// static var fileTemplate: String {
|
||||
// """
|
||||
// # vi: ft=sh
|
||||
//
|
||||
// # Example configuration, uncomment the lines and set the values appropriate for your
|
||||
// # usage.
|
||||
//
|
||||
// # Set this to the location of the ansible-hpa-playbook on your local machine.
|
||||
// #HPA_PLAYBOOK_DIR="/path/to/ansible-hpa-playbook"
|
||||
//
|
||||
// # Set this to the location of a template repository, which is used to create new assessment projects.
|
||||
// #HPA_TEMPLATE_REPO="https://git.example.com/your/template.git"
|
||||
//
|
||||
// # Specify a branch, version, or sha of the template repository.
|
||||
// #HPA_TEMPLATE_VERSION="main" # branch, version, or sha
|
||||
//
|
||||
// # Set this to a location of a template directory to use to create new projects.
|
||||
// #HPA_TEMPLATE_DIR="/path/to/local/template"
|
||||
//
|
||||
// # Extra arguments that get passed directly to the ansible-playbook command.
|
||||
// #HPA_DEFAULT_PLAYBOOK_ARGS="--vault-id=consults@$SCRIPTS/vault-gopass-client"
|
||||
//
|
||||
// """
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// extension [String: String] {
|
||||
// fileprivate func value(for codingKey: Configuration.CodingKeys) -> String? {
|
||||
// self[codingKey.rawValue]
|
||||
// }
|
||||
//
|
||||
// fileprivate func array(for codingKey: Configuration.CodingKeys) -> [String]? {
|
||||
// value(for: codingKey).flatMap { $0.split(separator: ",").map(String.init) }
|
||||
// }
|
||||
// }
|
||||
212
Sources/ConfigurationClient/ConfigurationClient.swift
Normal file
212
Sources/ConfigurationClient/ConfigurationClient.swift
Normal file
@@ -0,0 +1,212 @@
|
||||
import CodersClient
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import FileClient
|
||||
import Foundation
|
||||
import ShellClient
|
||||
|
||||
public extension DependencyValues {
|
||||
var configuration: 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 {
|
||||
if !force {
|
||||
guard !fileManager.fileExists(file.url) else {
|
||||
throw ConfigurationError.fileExists(path: file.path)
|
||||
}
|
||||
}
|
||||
|
||||
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, file.url)
|
||||
} else {
|
||||
// Json does not allow comments, so we write the mock configuration
|
||||
// to the file path.
|
||||
try await write(.mock, to: file, 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)
|
||||
}
|
||||
28
Sources/ConfigurationClient/Constants.swift
Normal file
28
Sources/ConfigurationClient/Constants.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
@_spi(Internal)
|
||||
public enum EnvironmentKey {
|
||||
static let xdgConfigHome = "XDG_CONFIG_HOME"
|
||||
static let hpaConfigHome = "HPA_CONFIG_HOME"
|
||||
static let hpaConfigFile = "HPA_CONFIG_FILE"
|
||||
}
|
||||
|
||||
@_spi(Internal)
|
||||
public enum HPAKey {
|
||||
public static let configDirName = "hpa"
|
||||
public static let resourceFileName = "hpa"
|
||||
public static let resourceFileExtension = "toml"
|
||||
public static let defaultFileName = "config.toml"
|
||||
}
|
||||
|
||||
extension [String: String] {
|
||||
var xdgConfigHome: String {
|
||||
self[EnvironmentKey.xdgConfigHome] ?? "~/.config"
|
||||
}
|
||||
|
||||
var hpaConfigHome: String? {
|
||||
self[EnvironmentKey.hpaConfigHome]
|
||||
}
|
||||
|
||||
var hpaConfigFile: String? {
|
||||
self[EnvironmentKey.hpaConfigFile]
|
||||
}
|
||||
}
|
||||
53
Sources/ConfigurationClient/File.swift
Normal file
53
Sources/ConfigurationClient/File.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
/// Represents a file location and type on disk for a configuration file.
|
||||
public enum File: Equatable, Sendable {
|
||||
|
||||
case json(URL)
|
||||
case toml(URL)
|
||||
|
||||
public init?(_ url: URL) {
|
||||
if url.cleanFilePath.hasSuffix("json") {
|
||||
self = .json(url)
|
||||
} else if url.cleanFilePath.hasSuffix("toml") {
|
||||
self = .toml(url)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public init?(_ path: String) {
|
||||
self.init(URL(filePath: path))
|
||||
}
|
||||
|
||||
public static func json(_ path: String) -> Self {
|
||||
.json(URL(filePath: path))
|
||||
}
|
||||
|
||||
public static func toml(_ path: String) -> Self {
|
||||
.toml(URL(filePath: path))
|
||||
}
|
||||
|
||||
public var url: URL {
|
||||
switch self {
|
||||
case let .json(url): return url
|
||||
case let .toml(url): return url
|
||||
}
|
||||
}
|
||||
|
||||
public var path: String {
|
||||
url.cleanFilePath
|
||||
}
|
||||
|
||||
public static var `default`: Self {
|
||||
.toml("~/.config/\(HPAKey.configDirName)/\(HPAKey.defaultFileName)")
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(Internal)
|
||||
public extension URL {
|
||||
|
||||
var cleanFilePath: String {
|
||||
absoluteString.replacing("file://", with: "")
|
||||
}
|
||||
}
|
||||
63
Sources/FileClient/FileClient.swift
Normal file
63
Sources/FileClient/FileClient.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
|
||||
public extension DependencyValues {
|
||||
var fileClient: FileClient {
|
||||
get { self[FileClient.self] }
|
||||
set { self[FileClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
public struct FileClient: Sendable {
|
||||
public var copy: @Sendable (URL, URL) async throws -> Void
|
||||
public var fileExists: @Sendable (URL) -> Bool = { _ in true }
|
||||
public var homeDirectory: @Sendable () -> URL = { URL(filePath: "~/") }
|
||||
public var load: @Sendable (URL) async throws -> Data
|
||||
public var write: @Sendable (Data, URL) async throws -> Void
|
||||
}
|
||||
|
||||
extension FileClient: DependencyKey {
|
||||
public static let testValue: FileClient = Self()
|
||||
|
||||
public static var liveValue: Self {
|
||||
let manager = LiveFileClient()
|
||||
return .init {
|
||||
try await manager.copy($0, to: $1)
|
||||
} fileExists: { url in
|
||||
manager.fileExists(at: url)
|
||||
} homeDirectory: {
|
||||
manager.homeDirectory()
|
||||
} load: { url in
|
||||
try await manager.load(from: url)
|
||||
} write: { data, url in
|
||||
try await manager.write(data, to: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveFileClient: Sendable {
|
||||
|
||||
private var manager: FileManager { FileManager.default }
|
||||
|
||||
func copy(_ url: URL, to toUrl: URL) async throws {
|
||||
try manager.copyItem(at: url, to: toUrl)
|
||||
}
|
||||
|
||||
func fileExists(at url: URL) -> Bool {
|
||||
manager.fileExists(atPath: url.absoluteString.replacing("file://", with: ""))
|
||||
}
|
||||
|
||||
func homeDirectory() -> URL {
|
||||
manager.homeDirectoryForCurrentUser
|
||||
}
|
||||
|
||||
func load(from url: URL) async throws -> Data {
|
||||
try Data(contentsOf: url)
|
||||
}
|
||||
|
||||
func write(_ data: Data, to url: URL) async throws {
|
||||
try data.write(to: url)
|
||||
}
|
||||
}
|
||||
130
Sources/TestSupport/TestSupport.swift
Normal file
130
Sources/TestSupport/TestSupport.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
@_exported import Dependencies
|
||||
@_exported import ShellClient
|
||||
|
||||
public protocol TestCase {}
|
||||
|
||||
public extension TestCase {
|
||||
func withTestLogger(
|
||||
key: String,
|
||||
logLevel: Logger.Level = .debug,
|
||||
operation: @escaping @Sendable () throws -> Void
|
||||
) rethrows {
|
||||
try TestSupport.withTestLogger(
|
||||
key: key,
|
||||
label: "\(Self.self)",
|
||||
logLevel: logLevel
|
||||
) {
|
||||
try operation()
|
||||
}
|
||||
}
|
||||
|
||||
func withTestLogger(
|
||||
key: String,
|
||||
logLevel: Logger.Level = .debug,
|
||||
dependencies setupDependencies: @escaping (inout DependencyValues) -> Void,
|
||||
operation: @escaping @Sendable () throws -> Void
|
||||
) rethrows {
|
||||
try TestSupport.withTestLogger(
|
||||
key: key,
|
||||
label: "\(Self.self)",
|
||||
logLevel: logLevel,
|
||||
dependencies: setupDependencies
|
||||
) {
|
||||
try operation()
|
||||
}
|
||||
}
|
||||
|
||||
func withTestLogger(
|
||||
key: String,
|
||||
logLevel: Logger.Level = .debug,
|
||||
operation: @escaping @Sendable () async throws -> Void
|
||||
) async rethrows {
|
||||
try await TestSupport.withTestLogger(
|
||||
key: key,
|
||||
label: "\(Self.self)",
|
||||
logLevel: logLevel
|
||||
) {
|
||||
try await operation()
|
||||
}
|
||||
}
|
||||
|
||||
func withTestLogger(
|
||||
key: String,
|
||||
logLevel: Logger.Level = .debug,
|
||||
dependencies setupDependencies: @escaping (inout DependencyValues) -> Void,
|
||||
operation: @escaping @Sendable () async throws -> Void
|
||||
) async rethrows {
|
||||
try await TestSupport.withTestLogger(
|
||||
key: key,
|
||||
label: "\(Self.self)",
|
||||
logLevel: logLevel,
|
||||
dependencies: setupDependencies
|
||||
) {
|
||||
try await operation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func withTestLogger(
|
||||
key: String,
|
||||
label: String,
|
||||
logLevel: Logger.Level = .debug,
|
||||
operation: @escaping @Sendable () throws -> Void
|
||||
) rethrows {
|
||||
try withDependencies {
|
||||
$0.logger = .init(label: label)
|
||||
$0.logger[metadataKey: "test"] = "\(key)"
|
||||
$0.logger.logLevel = logLevel
|
||||
} operation: {
|
||||
try operation()
|
||||
}
|
||||
}
|
||||
|
||||
public func withTestLogger(
|
||||
key: String,
|
||||
label: String,
|
||||
logLevel: Logger.Level = .debug,
|
||||
dependencies setupDependencies: @escaping (inout DependencyValues) -> Void,
|
||||
operation: @escaping @Sendable () throws -> Void
|
||||
) rethrows {
|
||||
try withDependencies {
|
||||
$0.logger = .init(label: label)
|
||||
$0.logger[metadataKey: "test"] = "\(key)"
|
||||
$0.logger.logLevel = logLevel
|
||||
setupDependencies(&$0)
|
||||
} operation: {
|
||||
try operation()
|
||||
}
|
||||
}
|
||||
|
||||
public func withTestLogger(
|
||||
key: String,
|
||||
label: String,
|
||||
logLevel: Logger.Level = .debug,
|
||||
operation: @escaping @Sendable () async throws -> Void
|
||||
) async rethrows {
|
||||
try await withDependencies {
|
||||
$0.logger = .init(label: label)
|
||||
$0.logger[metadataKey: "test"] = "\(key)"
|
||||
$0.logger.logLevel = logLevel
|
||||
} operation: {
|
||||
try await operation()
|
||||
}
|
||||
}
|
||||
|
||||
public func withTestLogger(
|
||||
key: String,
|
||||
label: String,
|
||||
logLevel: Logger.Level = .debug,
|
||||
dependencies setupDependencies: @escaping (inout DependencyValues) -> Void,
|
||||
operation: @escaping @Sendable () async throws -> Void
|
||||
) async rethrows {
|
||||
try await withDependencies {
|
||||
$0.logger = .init(label: label)
|
||||
$0.logger[metadataKey: "test"] = "\(key)"
|
||||
$0.logger.logLevel = logLevel
|
||||
setupDependencies(&$0)
|
||||
} operation: {
|
||||
try await operation()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import ArgumentParser
|
||||
import CliClient
|
||||
import ConfigurationClient
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import Logging
|
||||
@@ -61,12 +62,14 @@ struct CreateCommand: AsyncParsableCommand {
|
||||
|
||||
private func _run() async throws {
|
||||
try await withSetupLogger(commandName: Self.commandName, globals: globals) {
|
||||
@Dependency(\.coders) var coders
|
||||
@Dependency(\.cliClient) var cliClient
|
||||
@Dependency(\.configuration) var configurationClient
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
let encoder = cliClient.encoder()
|
||||
let encoder = coders.jsonEncoder()
|
||||
|
||||
let configuration = try cliClient.loadConfiguration()
|
||||
let configuration = try await configurationClient.findAndLoad()
|
||||
|
||||
logger.debug("Configuration: \(configuration)")
|
||||
|
||||
@@ -102,9 +105,9 @@ private func parseOptions(
|
||||
logger: Logger,
|
||||
encoder: JSONEncoder
|
||||
) throws -> Data {
|
||||
let templateDir = command.templateDir ?? configuration.templateDir
|
||||
let templateRepo = command.repo ?? configuration.templateRepo
|
||||
let version = (command.branch ?? configuration.templateRepoVersion) ?? "main"
|
||||
let templateDir = command.templateDir ?? configuration.template.directory
|
||||
let templateRepo = command.repo ?? configuration.template.url
|
||||
let version = (command.branch ?? configuration.template.version) ?? "main"
|
||||
|
||||
logger.debug("""
|
||||
(\(command.localTemplateDir), \(String(describing: templateDir)), \(String(describing: templateRepo)))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ArgumentParser
|
||||
import CliClient
|
||||
import ConfigurationClient
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import Logging
|
||||
@@ -15,11 +16,12 @@ func runPlaybook(
|
||||
) async throws {
|
||||
try await withSetupLogger(commandName: commandName, globals: globals) {
|
||||
@Dependency(\.cliClient) var cliClient
|
||||
@Dependency(\.configuration) var configurationClient
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
logger.debug("Begin run playbook: \(globals)")
|
||||
|
||||
let configuration = try cliClient.ensuredConfiguration(configuration)
|
||||
let configuration = try await configurationClient.findAndLoad()
|
||||
|
||||
logger.debug("Configuration: \(configuration)")
|
||||
|
||||
@@ -27,7 +29,7 @@ func runPlaybook(
|
||||
globals: globals,
|
||||
configuration: configuration,
|
||||
globalsKeyPath: \.playbookDir,
|
||||
configurationKeyPath: \.playbookDir
|
||||
configurationKeyPath: \.playbook?.directory
|
||||
)
|
||||
let playbook = "\(playbookDir)/\(Constants.playbookFileName)"
|
||||
|
||||
@@ -35,11 +37,11 @@ func runPlaybook(
|
||||
globals: globals,
|
||||
configuration: configuration,
|
||||
globalsKeyPath: \.inventoryPath,
|
||||
configurationKeyPath: \.inventoryPath
|
||||
configurationKeyPath: \.playbook?.inventory
|
||||
)) ?? "\(playbookDir)/\(Constants.inventoryFileName)"
|
||||
|
||||
let defaultArgs = (configuration.defaultPlaybookArgs ?? [])
|
||||
+ (configuration.defaultVaultArgs ?? [])
|
||||
let defaultArgs = (configuration.args ?? [])
|
||||
+ (configuration.useVaultArgs ? configuration.vault.args ?? [] : [])
|
||||
|
||||
try await cliClient.runCommand(
|
||||
quiet: globals.quietOnlyPlaybook ? true : globals.quiet,
|
||||
@@ -70,15 +72,6 @@ func runPlaybook(
|
||||
)
|
||||
}
|
||||
|
||||
extension CliClient {
|
||||
func ensuredConfiguration(_ configuration: Configuration?) throws -> Configuration {
|
||||
guard let configuration else {
|
||||
return try loadConfiguration()
|
||||
}
|
||||
return configuration
|
||||
}
|
||||
}
|
||||
|
||||
extension BasicGlobalOptions {
|
||||
|
||||
var shellOrDefault: ShellCommand.Shell {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ConfigurationClient
|
||||
import Dependencies
|
||||
import ShellClient
|
||||
|
||||
@@ -8,11 +9,12 @@ func runVault(
|
||||
) async throws {
|
||||
try await withSetupLogger(commandName: commandName, globals: options.globals) {
|
||||
@Dependency(\.cliClient) var cliClient
|
||||
@Dependency(\.configuration) var configurationClient
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
logger.debug("Begin run vault: \(options)")
|
||||
|
||||
let configuration = try cliClient.ensuredConfiguration(nil)
|
||||
let configuration = try await configurationClient.findAndLoad()
|
||||
logger.debug("Configuration: \(configuration)")
|
||||
|
||||
let path: String
|
||||
@@ -24,7 +26,7 @@ func runVault(
|
||||
|
||||
logger.debug("Vault path: \(path)")
|
||||
|
||||
let defaultArgs = configuration.defaultVaultArgs ?? []
|
||||
let defaultArgs = configuration.vault.args ?? []
|
||||
|
||||
var vaultArgs = ["ansible-vault"]
|
||||
+ args
|
||||
@@ -34,7 +36,7 @@ func runVault(
|
||||
|
||||
if args.contains("encrypt"),
|
||||
!vaultArgs.contains("--encrypt-vault-id"),
|
||||
let id = configuration.defaultVaultEncryptId
|
||||
let id = configuration.vault.encryptId
|
||||
{
|
||||
vaultArgs.append(contentsOf: ["--encrypt-vault-id", id])
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
|
||||
try await _run()
|
||||
}
|
||||
|
||||
// FIX:
|
||||
private func _run() async throws {
|
||||
try await withSetupLogger(commandName: Self.commandName, globals: globals) {
|
||||
@Dependency(\.cliClient) var cliClient
|
||||
@@ -66,7 +67,8 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
|
||||
actualPath = "\(path)/config"
|
||||
}
|
||||
|
||||
try cliClient.createConfiguration(actualPath, json)
|
||||
fatalError()
|
||||
// try cliClient.createConfiguration(actualPath, json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,154 +1,124 @@
|
||||
@_spi(Internal) import CliClient
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import ShellClient
|
||||
import Testing
|
||||
import TOMLKit
|
||||
|
||||
@Suite("CliClientTests")
|
||||
struct CliClientTests {
|
||||
|
||||
@Test
|
||||
func testLiveFileClient() {
|
||||
withTestLogger(key: "testFindConfigPaths", logLevel: .trace) {
|
||||
$0.fileClient = .liveValue
|
||||
} operation: {
|
||||
@Dependency(\.fileClient) var fileClient
|
||||
let homeDir = fileClient.homeDir()
|
||||
#expect(fileClient.isDirectory(homeDir))
|
||||
#expect(fileClient.isReadable(homeDir))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func testFindConfigPaths() throws {
|
||||
withTestLogger(key: "testFindConfigPaths", logLevel: .trace) {
|
||||
$0.fileClient = .liveValue
|
||||
} operation: {
|
||||
@Dependency(\.logger) var logger
|
||||
let configURL = Bundle.module.url(forResource: "config", withExtension: "json")!
|
||||
var env = [
|
||||
"HPA_CONFIG_FILE": path(for: configURL)
|
||||
]
|
||||
var url = try? findConfigurationFiles(env: env)
|
||||
#expect(url != nil)
|
||||
|
||||
env["HPA_CONFIG_FILE"] = nil
|
||||
env["HPA_CONFIG_HOME"] = path(for: configURL.deletingLastPathComponent())
|
||||
url = try? findConfigurationFiles(env: env)
|
||||
#expect(url != nil)
|
||||
|
||||
env["HPA_CONFIG_HOME"] = nil
|
||||
env["PWD"] = path(for: configURL.deletingLastPathComponent())
|
||||
url = try? findConfigurationFiles(env: env)
|
||||
#expect(url != nil)
|
||||
|
||||
env["PWD"] = nil
|
||||
env["XDG_CONFIG_HOME"] = path(for: configURL.deletingLastPathComponent())
|
||||
url = try? findConfigurationFiles(env: env)
|
||||
#expect(url != nil)
|
||||
|
||||
withDependencies {
|
||||
$0.fileClient.homeDir = { configURL.deletingLastPathComponent() }
|
||||
} operation: {
|
||||
url = try? findConfigurationFiles(env: [:])
|
||||
#expect(url != nil)
|
||||
}
|
||||
|
||||
url = try? findConfigurationFiles(env: [:])
|
||||
#expect(url == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func loadConfiguration() throws {
|
||||
let configURL = Bundle.module.url(forResource: "config", withExtension: "json")!
|
||||
let configData = try Data(contentsOf: configURL)
|
||||
let decodedConfig = try JSONDecoder().decode(Configuration.self, from: configData)
|
||||
|
||||
try withTestLogger(key: "loadConfiguration", logLevel: .debug) {
|
||||
$0.fileClient = .liveValue
|
||||
} operation: {
|
||||
@Dependency(\.logger) var logger
|
||||
let client = CliClient.live(env: ["HPA_CONFIG_FILE": path(for: configURL)])
|
||||
let config = try client.loadConfiguration()
|
||||
#expect(config == decodedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["config", "config.json"])
|
||||
func createConfiguration(filePath: String) throws {
|
||||
try withTestLogger(key: "createConfiguration", logLevel: .trace) {
|
||||
$0.fileClient = .liveValue
|
||||
} operation: {
|
||||
let client = CliClient.liveValue
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
|
||||
let tempPath = path(for: tempDir.appending(path: filePath))
|
||||
|
||||
try client.createConfiguration(path: tempPath, json: filePath.contains(".json"))
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: tempPath))
|
||||
|
||||
do {
|
||||
try client.createConfiguration(path: tempPath, json: true)
|
||||
#expect(Bool(false))
|
||||
} catch {
|
||||
#expect(Bool(true))
|
||||
}
|
||||
|
||||
try FileManager.default.removeItem(atPath: tempPath)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func findVaultFile() throws {
|
||||
try withTestLogger(key: "findVaultFile", logLevel: .trace) {
|
||||
$0.fileClient = .liveValue
|
||||
} operation: {
|
||||
@Dependency(\.fileClient) var fileClient
|
||||
let vaultUrl = Bundle.module.url(forResource: "vault", withExtension: "yml")!
|
||||
let vaultDir = vaultUrl.deletingLastPathComponent()
|
||||
let url = try fileClient.findVaultFile(path(for: vaultDir))
|
||||
#expect(url == vaultUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// @_spi(Internal) import CliClient
|
||||
// import Dependencies
|
||||
// import Foundation
|
||||
// import ShellClient
|
||||
// import Testing
|
||||
// import TOMLKit
|
||||
//
|
||||
// @Suite("CliClientTests")
|
||||
// struct CliClientTests {
|
||||
//
|
||||
// @Test
|
||||
// func writeToml() throws {
|
||||
// let encoded: String = try TOMLEncoder().encode(Configuration2.mock)
|
||||
// try encoded.write(to: URL(filePath: "hpa.toml"), atomically: true, encoding: .utf8)
|
||||
// func testLiveFileClient() {
|
||||
// withTestLogger(key: "testFindConfigPaths", logLevel: .trace) {
|
||||
// $0.fileClient = .liveValue
|
||||
// } operation: {
|
||||
// @Dependency(\.fileClient) var fileClient
|
||||
// let homeDir = fileClient.homeDir()
|
||||
// #expect(fileClient.isDirectory(homeDir))
|
||||
// #expect(fileClient.isReadable(homeDir))
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// func testFindConfigPaths() throws {
|
||||
// withTestLogger(key: "testFindConfigPaths", logLevel: .trace) {
|
||||
// $0.fileClient = .liveValue
|
||||
// } operation: {
|
||||
// @Dependency(\.logger) var logger
|
||||
// let configURL = Bundle.module.url(forResource: "config", withExtension: "json")!
|
||||
// var env = [
|
||||
// "HPA_CONFIG_FILE": path(for: configURL)
|
||||
// ]
|
||||
// var url = try? findConfigurationFiles(env: env)
|
||||
// #expect(url != nil)
|
||||
//
|
||||
// env["HPA_CONFIG_FILE"] = nil
|
||||
// env["HPA_CONFIG_HOME"] = path(for: configURL.deletingLastPathComponent())
|
||||
// url = try? findConfigurationFiles(env: env)
|
||||
// #expect(url != nil)
|
||||
//
|
||||
// env["HPA_CONFIG_HOME"] = nil
|
||||
// env["PWD"] = path(for: configURL.deletingLastPathComponent())
|
||||
// url = try? findConfigurationFiles(env: env)
|
||||
// #expect(url != nil)
|
||||
//
|
||||
// env["PWD"] = nil
|
||||
// env["XDG_CONFIG_HOME"] = path(for: configURL.deletingLastPathComponent())
|
||||
// url = try? findConfigurationFiles(env: env)
|
||||
// #expect(url != nil)
|
||||
//
|
||||
// withDependencies {
|
||||
// $0.fileClient.homeDir = { configURL.deletingLastPathComponent() }
|
||||
// } operation: {
|
||||
// url = try? findConfigurationFiles(env: [:])
|
||||
// #expect(url != nil)
|
||||
// }
|
||||
//
|
||||
// url = try? findConfigurationFiles(env: [:])
|
||||
// #expect(url == nil)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // @Test
|
||||
// // func loadConfiguration() throws {
|
||||
// // let configURL = Bundle.module.url(forResource: "config", withExtension: "json")!
|
||||
// // let configData = try Data(contentsOf: configURL)
|
||||
// // let decodedConfig = try JSONDecoder().decode(Configuration.self, from: configData)
|
||||
// //
|
||||
// // try withTestLogger(key: "loadConfiguration", logLevel: .debug) {
|
||||
// // $0.fileClient = .liveValue
|
||||
// // } operation: {
|
||||
// // @Dependency(\.logger) var logger
|
||||
// // let client = CliClient.live(env: ["HPA_CONFIG_FILE": path(for: configURL)])
|
||||
// // let config = try client.loadConfiguration()
|
||||
// // #expect(config == decodedConfig)
|
||||
// // }
|
||||
// // }
|
||||
//
|
||||
// @Test(arguments: ["config", "config.json"])
|
||||
// func createConfiguration(filePath: String) throws {
|
||||
// try withTestLogger(key: "createConfiguration", logLevel: .trace) {
|
||||
// $0.fileClient = .liveValue
|
||||
// } operation: {
|
||||
// let client = CliClient.liveValue
|
||||
// let tempDir = FileManager.default.temporaryDirectory
|
||||
//
|
||||
// let tempPath = path(for: tempDir.appending(path: filePath))
|
||||
//
|
||||
// try client.createConfiguration(path: tempPath, json: filePath.contains(".json"))
|
||||
//
|
||||
// #expect(FileManager.default.fileExists(atPath: tempPath))
|
||||
//
|
||||
// do {
|
||||
// try client.createConfiguration(path: tempPath, json: true)
|
||||
// #expect(Bool(false))
|
||||
// } catch {
|
||||
// #expect(Bool(true))
|
||||
// }
|
||||
//
|
||||
// try FileManager.default.removeItem(atPath: tempPath)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// func findVaultFile() throws {
|
||||
// try withTestLogger(key: "findVaultFile", logLevel: .trace) {
|
||||
// $0.fileClient = .liveValue
|
||||
// } operation: {
|
||||
// @Dependency(\.fileClient) var fileClient
|
||||
// let vaultUrl = Bundle.module.url(forResource: "vault", withExtension: "yml")!
|
||||
// let vaultDir = vaultUrl.deletingLastPathComponent()
|
||||
// let url = try fileClient.findVaultFile(path(for: vaultDir))
|
||||
// #expect(url == vaultUrl)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // @Test
|
||||
// // func writeToml() throws {
|
||||
// // let encoded: String = try TOMLEncoder().encode(Configuration.mock)
|
||||
// // try encoded.write(to: URL(filePath: "hpa.toml"), atomically: true, encoding: .utf8)
|
||||
// // }
|
||||
// }
|
||||
}
|
||||
|
||||
func withTestLogger(
|
||||
key: String,
|
||||
label: String = "CliClientTests",
|
||||
logLevel: Logger.Level = .debug,
|
||||
operation: @escaping @Sendable () throws -> Void
|
||||
) rethrows {
|
||||
try withDependencies {
|
||||
$0.logger = .init(label: label)
|
||||
$0.logger[metadataKey: "test"] = "\(key)"
|
||||
$0.logger.logLevel = logLevel
|
||||
} operation: {
|
||||
try operation()
|
||||
}
|
||||
}
|
||||
|
||||
func withTestLogger(
|
||||
key: String,
|
||||
label: String = "CliClientTests",
|
||||
logLevel: Logger.Level = .debug,
|
||||
dependencies setupDependencies: @escaping (inout DependencyValues) -> Void,
|
||||
operation: @escaping @Sendable () throws -> Void
|
||||
) rethrows {
|
||||
try withDependencies {
|
||||
$0.logger = .init(label: label)
|
||||
$0.logger[metadataKey: "test"] = "\(key)"
|
||||
$0.logger.logLevel = logLevel
|
||||
setupDependencies(&$0)
|
||||
} operation: {
|
||||
try operation()
|
||||
}
|
||||
}
|
||||
|
||||
190
Tests/ConfigurationClientTests/ConfigurationClientTests.swift
Normal file
190
Tests/ConfigurationClientTests/ConfigurationClientTests.swift
Normal file
@@ -0,0 +1,190 @@
|
||||
@_spi(Internal) import ConfigurationClient
|
||||
import Foundation
|
||||
import Testing
|
||||
import TestSupport
|
||||
|
||||
@Suite("ConfigurationClientTests")
|
||||
struct ConfigurationClientTests: TestCase {
|
||||
|
||||
@Test
|
||||
func sanity() {
|
||||
withTestLogger(key: "sanity") {
|
||||
@Dependency(\.logger) var logger
|
||||
logger.debug("Testing sanity.")
|
||||
#expect(Bool(true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["config.toml", "config.json"])
|
||||
func generateConfigFile(fileName: String) async throws {
|
||||
try await withTestLogger(key: "generateConfigFile") {
|
||||
$0.fileClient = .liveValue
|
||||
} operation: {
|
||||
@Dependency(\.logger) var logger
|
||||
@Dependency(\.fileClient) var fileClient
|
||||
let configuration = ConfigurationClient.liveValue
|
||||
|
||||
try await withTemporaryDirectory { tempDir in
|
||||
let tempFile = tempDir.appending(path: fileName)
|
||||
try await configuration.generate(at: File(tempFile)!, force: false)
|
||||
#expect(FileManager.default.fileExists(atPath: tempFile.cleanFilePath))
|
||||
#expect(fileClient.fileExists(tempFile))
|
||||
|
||||
// Ensure that we do not overwrite files if they exist.
|
||||
do {
|
||||
try await configuration.generate(at: File(tempFile)!, force: false)
|
||||
#expect(Bool(false))
|
||||
} catch {
|
||||
#expect(Bool(true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["config.toml", "config.json", nil])
|
||||
func loadConfigFile(fileName: String?) async throws {
|
||||
try await withTestLogger(key: "generateConfigFile") {
|
||||
$0.fileClient = .liveValue
|
||||
} operation: {
|
||||
@Dependency(\.logger) var logger
|
||||
@Dependency(\.fileClient) var fileClient
|
||||
let configuration = ConfigurationClient.liveValue
|
||||
|
||||
guard let fileName else {
|
||||
let loaded = try await configuration.load(nil)
|
||||
#expect(loaded == .init())
|
||||
return
|
||||
}
|
||||
|
||||
try await withGeneratedConfigFile(named: fileName, client: configuration) { file in
|
||||
let loaded = try await configuration.load(file)
|
||||
#expect(loaded == .mock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["config.toml", "config.json", ".hparc.json", ".hparc.toml"])
|
||||
func findConfiguration(fileName: String) async throws {
|
||||
try await withTestLogger(key: "findConfiguration") {
|
||||
$0.fileClient = .liveValue
|
||||
} operation: {
|
||||
@Dependency(\.logger) var logger
|
||||
@Dependency(\.fileClient) var fileClient
|
||||
let client = ConfigurationClient.liveValue
|
||||
|
||||
try await withGeneratedConfigFile(named: fileName, client: client) { file in
|
||||
for environment in generateFindEnvironments(file: file) {
|
||||
if let home = environment["HOME"] {
|
||||
try await withDependencies {
|
||||
$0.fileClient.homeDirectory = { URL(filePath: home) }
|
||||
} operation: {
|
||||
let configuration = ConfigurationClient.live(environment: environment)
|
||||
let found = try await configuration.find()
|
||||
#expect(found == file)
|
||||
}
|
||||
} else {
|
||||
let configuration = ConfigurationClient.live(environment: environment)
|
||||
let found = try await configuration.find()
|
||||
#expect(found == file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["config.toml", "config.json", ".hparc.json", ".hparc.toml"])
|
||||
func findXdgConfiguration(fileName: String) async throws {
|
||||
try await withTestLogger(key: "findXdgConfiguration") {
|
||||
$0.fileClient = .liveValue
|
||||
} operation: {
|
||||
@Dependency(\.logger) var logger
|
||||
@Dependency(\.fileClient) var fileClient
|
||||
let client = ConfigurationClient.liveValue
|
||||
|
||||
try await withGeneratedXDGConfigFile(named: fileName, client: client) { file, xdgDir in
|
||||
let environment = ["XDG_CONFIG_HOME": xdgDir.cleanFilePath]
|
||||
let configuration = ConfigurationClient.live(environment: environment)
|
||||
let found = try await configuration.find()
|
||||
#expect(found == file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func testFailingFind() async {
|
||||
await withTestLogger(key: "testFailingFind") {
|
||||
$0.fileClient = .liveValue
|
||||
} operation: {
|
||||
await withTemporaryDirectory { tempDir in
|
||||
let environment = [
|
||||
"PWD": tempDir.cleanFilePath,
|
||||
"HPA_CONFIG_HOME": tempDir.cleanFilePath
|
||||
]
|
||||
let configuration = ConfigurationClient.live(environment: environment)
|
||||
do {
|
||||
_ = try await configuration.find()
|
||||
#expect(Bool(false))
|
||||
} catch {
|
||||
#expect(Bool(true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generateFindEnvironments(file: File) -> [[String: String]] {
|
||||
let directory = file.url.deletingLastPathComponent().cleanFilePath
|
||||
|
||||
return [
|
||||
["PWD": directory],
|
||||
["HPA_CONFIG_HOME": directory],
|
||||
["HPA_CONFIG_FILE": file.path],
|
||||
["HOME": directory]
|
||||
]
|
||||
}
|
||||
|
||||
// swiftlint:disable force_try
|
||||
func withTemporaryDirectory(
|
||||
_ operation: @Sendable (URL) async throws -> Void
|
||||
) async rethrows {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
let tempDir = dir.appending(path: UUID().uuidString)
|
||||
|
||||
try! FileManager.default.createDirectory(
|
||||
atPath: tempDir.cleanFilePath,
|
||||
withIntermediateDirectories: false
|
||||
)
|
||||
try await operation(tempDir)
|
||||
try! FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
// swiftlint:enable force_try
|
||||
|
||||
func withGeneratedConfigFile(
|
||||
named fileName: String,
|
||||
client: ConfigurationClient,
|
||||
_ operation: @Sendable (File) async throws -> Void
|
||||
) async rethrows {
|
||||
try await withTemporaryDirectory { tempDir in
|
||||
let file = File(tempDir.appending(path: fileName))!
|
||||
try await client.generate(at: file)
|
||||
try await operation(file)
|
||||
}
|
||||
}
|
||||
|
||||
func withGeneratedXDGConfigFile(
|
||||
named fileName: String,
|
||||
client: ConfigurationClient,
|
||||
_ operation: @Sendable (File, URL) async throws -> Void
|
||||
) async rethrows {
|
||||
try await withTemporaryDirectory { tempDir in
|
||||
let xdgDir = tempDir.appending(path: HPAKey.configDirName)
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: xdgDir.cleanFilePath,
|
||||
withIntermediateDirectories: false
|
||||
)
|
||||
let file = File(xdgDir.appending(path: fileName))!
|
||||
try await client.generate(at: file)
|
||||
try await operation(file, tempDir)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user