feat: Begins work on supporting toml for configuration.

This commit is contained in:
2024-12-09 17:00:52 -05:00
parent a8d35fed37
commit 87390c4b63
14 changed files with 354 additions and 196 deletions

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
.nvim/*
.swiftpm/*

View File

@@ -0,0 +1,24 @@
{
"configurations" : [
{
"id" : "BE1E3DDC-11A9-41D4-B82D-5EF22CCB79D9",
"name" : "Configuration 1",
"options" : {
}
}
],
"defaultOptions" : {
"testTimeoutsEnabled" : true
},
"testTargets" : [
{
"target" : {
"containerPath" : "container:",
"identifier" : "CliClientTests",
"name" : "CliClientTests"
}
}
],
"version" : 1
}

View File

@@ -1,5 +1,5 @@
{
"originHash" : "def70abefdc3133b863ecf24e1b413af00133f95cabea13d0ff42d7283910a58",
"originHash" : "57500b96c3cc5835f23b47b8fdf0a0f69711487d67b69c5ab659837d02308a93",
"pins" : [
{
"identity" : "combine-schedulers",
@@ -100,6 +100,15 @@
"version" : "600.0.1"
}
},
{
"identity" : "tomlkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/LebJe/TOMLKit.git",
"state" : {
"revision" : "ec6198d37d495efc6acd4dffbd262cdca7ff9b3f",
"version" : "0.6.0"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",

View File

@@ -14,7 +14,8 @@ let package = Package(
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.5.2"),
.package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.1.0"),
.package(url: "https://git.housh.dev/michael/swift-cli-doc.git", from: "0.2.0")
.package(url: "https://git.housh.dev/michael/swift-cli-doc.git", from: "0.2.0"),
.package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.5.0")
],
targets: [
.executableTarget(
@@ -35,6 +36,18 @@ let package = Package(
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.testTarget(name: "CliClientTests", dependencies: ["CliClient"])
.testTarget(
name: "CliClientTests",
dependencies: [
"CliClient",
.product(name: "TOMLKit", package: "TOMLKit")
],
resources: [
.copy("Resources/config.json"),
.copy("Resources/.hparc"),
.copy("Resources/vault.yml"),
.copy("Resources/hpa-playbook")
]
)
]
)

View File

@@ -3,6 +3,8 @@ import DependenciesMacros
import Foundation
import ShellClient
// TODO: Drop support for non-json configuration.
public extension DependencyValues {
var cliClient: CliClient {
get { self[CliClient.self] }
@@ -38,6 +40,7 @@ public struct CliClient: Sendable {
extension CliClient: DependencyKey {
// swiftlint:disable function_body_length
public static func live(
decoder: JSONDecoder = .init(),
encoder: JSONEncoder = .init(),
@@ -55,9 +58,10 @@ extension CliClient: DependencyKey {
var env = env
logger.trace("Loading configuration from: \(url)")
try fileClient.loadFile(url, &env, decoder)
guard let config = try fileClient.loadFile(url, &env, decoder) else {
return try .fromEnv(env)
}
return config
} runCommand: { args, quiet, shell in
@Dependency(\.asyncShellClient) var shellClient
@@ -105,6 +109,8 @@ extension CliClient: DependencyKey {
}
}
// swiftlint:enable function_body_length
public static var liveValue: CliClient {
.live(env: ProcessInfo.processInfo.environment)
}

View File

@@ -2,8 +2,105 @@ import Dependencies
import Foundation
import ShellClient
/// Represents the configuration.
public struct Configuration: Codable, Sendable {
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?

View File

@@ -13,13 +13,30 @@ public extension DependencyValues {
@_spi(Internal)
@DependencyClient
public struct FileClient: Sendable {
public var loadFile: @Sendable (URL, inout [String: String], JSONDecoder) throws -> Void
/// 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 findVaultFileInCurrentDirectory: @Sendable () throws -> URL?
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)
@@ -34,7 +51,7 @@ extension FileClient: DependencyKey {
isDirectory: { client.isDirectory(url: $0) },
isReadable: { client.isReadable(url: $0) },
fileExists: { client.fileExists(at: $0) },
findVaultFileInCurrentDirectory: { try client.findVaultFileInCurrentDirectory() },
findVaultFile: { try client.findVaultFile(in: $0) },
write: { path, data in
try data.write(to: URL(filePath: path))
}
@@ -68,8 +85,11 @@ private struct LiveFileClient: @unchecked Sendable {
fileManager.fileExists(atPath: path)
}
func findVaultFileInCurrentDirectory() throws -> URL? {
let urls = try fileManager.contentsOfDirectory(at: URL(filePath: "."), includingPropertiesForKeys: nil)
// 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
@@ -93,16 +113,18 @@ private struct LiveFileClient: @unchecked Sendable {
return nil
}
func loadFile(at url: URL, into env: inout [String: String], decoder: JSONDecoder) throws {
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)
let dict = (try? decoder.decode([String: String].self, from: data)) ?? [:]
env.merge(dict, uniquingKeysWith: { $1 })
return
return try decoder.decode(Configuration.self, from: data)
}
let string = try String(contentsOfFile: path(for: url), encoding: .utf8)
@@ -126,6 +148,7 @@ private struct LiveFileClient: @unchecked Sendable {
env[String(splitLine[0])] = String(splitLine[1])
}
}
return nil
}
}

View File

@@ -57,7 +57,8 @@ public func findConfigurationFiles(
throw ConfigurationError.configurationNotFound
}
func path(for url: URL) -> String {
@_spi(Internal)
public func path(for url: URL) -> String {
url.absoluteString.replacing("file://", with: "")
}

View File

@@ -1,34 +1,126 @@
@_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 {
try withTestLogger(key: "testFindConfigPaths") {
withTestLogger(key: "testFindConfigPaths", logLevel: .trace) {
$0.fileClient = .liveValue
} operation: {
@Dependency(\.logger) var logger
let urls = try findConfigurationFiles()
logger.debug("urls: \(urls)")
// #expect(urls.count == 1)
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.cliClient = .liveValue
$0.fileClient = .liveValue
} operation: {
@Dependency(\.cliClient) var client
@Dependency(\.logger) var logger
let client = CliClient.live(env: ["HPA_CONFIG_FILE": path(for: configURL)])
let config = try client.loadConfiguration()
logger.debug("\(config)")
#expect(config.playbookDir != nil)
#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(Configuration2.mock)
// try encoded.write(to: URL(filePath: "hpa.toml"), atomically: true, encoding: .utf8)
// }
}
func withTestLogger(
key: String,
label: String = "CliClientTests",

View File

@@ -0,0 +1,15 @@
{
"HPA_TEMPLATE_VERSION" : "main",
"HPA_TEMPLATE_DIR" : "/path/to/local/template",
"HPA_PLAYBOOK_DIR" : "/path/to/playbook",
"HPA_DEFAULT_VAULT_ARGS" : [
"--vault-id=myId@$SCRIPTS/vault-gopass-client"
],
"HPA_TEMPLATE_REPO" : "https://git.example.com/consult-template.git",
"HPA_DEFAULT_PLAYBOOK_ARGS" : [
"--tags",
"debug"
],
"HPA_DEFAULT_VAULT_ENCRYPT_ID" : "myId",
"HPA_DEFAULT_INVENTORY" : "/path/to/inventory.ini"
}

View File

@@ -0,0 +1,15 @@
{
"HPA_TEMPLATE_VERSION" : "main",
"HPA_TEMPLATE_DIR" : "/path/to/local/template",
"HPA_PLAYBOOK_DIR" : "/path/to/playbook",
"HPA_DEFAULT_VAULT_ARGS" : [
"--vault-id=myId@$SCRIPTS/vault-gopass-client"
],
"HPA_TEMPLATE_REPO" : "https://git.example.com/consult-template.git",
"HPA_DEFAULT_PLAYBOOK_ARGS" : [
"--tags",
"debug"
],
"HPA_DEFAULT_VAULT_ENCRYPT_ID" : "myId",
"HPA_DEFAULT_INVENTORY" : "/path/to/inventory.ini"
}

View File

@@ -0,0 +1,15 @@
{
"HPA_TEMPLATE_VERSION" : "main",
"HPA_TEMPLATE_DIR" : "/path/to/local/template",
"HPA_PLAYBOOK_DIR" : "/path/to/playbook",
"HPA_DEFAULT_VAULT_ARGS" : [
"--vault-id=myId@$SCRIPTS/vault-gopass-client"
],
"HPA_TEMPLATE_REPO" : "https://git.example.com/consult-template.git",
"HPA_DEFAULT_PLAYBOOK_ARGS" : [
"--tags",
"debug"
],
"HPA_DEFAULT_VAULT_ENCRYPT_ID" : "myId",
"HPA_DEFAULT_INVENTORY" : "/path/to/inventory.ini"
}

View File

@@ -0,0 +1,2 @@
---
# this is just here for testing, doesn't need to be encoded.

View File

@@ -1,156 +0,0 @@
@_spi(Internal) import CliDoc
@preconcurrency import Rainbow
import Testing
import XCTest
final class CliDocTests: XCTestCase {
override func setUp() {
super.setUp()
Rainbow.outputTarget = .console
Rainbow.enabled = true
}
func testStringChecks() {
let expected = "Foo".green.bold
XCTAssert("Foo".green.bold == expected)
XCTAssert(expected != "Foo")
}
func testRepeatingModifier() {
let node = AnyNode {
Text("foo").color(.green).style(.bold)
"\n".repeating(2)
Text("bar").repeating(2, separator: " ")
}
let expected = """
\("foo".green.bold)
bar bar
"""
XCTAssert(node.render() == expected)
}
func testGroup1() {
let arguments = [
(true, "foo bar"),
(false, """
foo
bar
""")
]
for (inline, expected) in arguments {
let node = AnyNode {
Group(separator: inline ? " " : "\n") {
Text("foo")
Text("bar")
}
}
XCTAssert(node.render() == expected)
}
}
func testHeader() {
let header = Header("Foo")
let expected = "\("Foo".yellow.bold)"
XCTAssert(header.render() == expected)
let header2 = Header {
"Foo".yellow.bold
}
XCTAssert(header2.render() == expected)
}
func testGroup() {
let group = Group {
Text("foo")
Text("bar")
}
XCTAssert(group.render() == "foo bar")
let group2 = Group(separator: "\n") {
Text("foo")
Text("bar")
}
let expected = """
foo
bar
"""
XCTAssert(group2.render() == expected)
}
func testLabeledContent() {
let node = LabeledContent("Foo") {
Text("Bar")
}
.labelStyle(.green)
.labelStyle(.bold)
let expected = """
\("Foo".green.bold)
Bar
"""
XCTAssert(node.render() == expected)
}
func testLabeledContent2() {
let node = LabeledContent2 {
"Foo"
} content: {
Text("Bar")
}
// .labelStyle(.green)
// .labelStyle(.bold)
let expected = """
Foo Bar
"""
XCTAssert(node.render() == expected)
print(type(of: node.body))
XCTAssertNotNil(node.body as? _ManyNode)
}
func testShellCommand() {
let node = ShellCommand {
"ls -lah"
}
let expected = " $ ls -lah"
XCTAssert(node.render() == expected)
}
func testDiscussion() {
let node = Discussion {
Group(separator: "\n") {
LabeledContent(separator: " ") {
"NOTE:".yellow.bold
} content: {
"Foo"
}
}
}
let expected = """
\("NOTE:".yellow.bold) Foo
"""
XCTAssert(node.render() == expected)
}
func testFooNode() {
let foo = Foo()
XCTAssertNotNil(foo.body as? LabeledContent)
}
func testGroup2() {
let node = Group2 {
Text("foo")
Text("bar")
}
print(node.render())
XCTAssertNotNil(node.body as? _ManyNode)
// XCTAssert(false)
}
}