From 87390c4b6325661c1c94b1e4ef3fb091e0ab1472 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Mon, 9 Dec 2024 17:00:52 -0500 Subject: [PATCH] feat: Begins work on supporting toml for configuration. --- .gitignore | 2 + .swiftpm/swift-hpa-Package.xctestplan | 24 +++ Package.resolved | 11 +- Package.swift | 17 +- Sources/CliClient/CliClient.swift | 12 +- Sources/CliClient/Configuration.swift | 101 +++++++++++- Sources/CliClient/FileClient.swift | 41 ++++- Sources/CliClient/Helpers.swift | 3 +- Tests/CliClientTests/CliClientTests.swift | 136 ++++++++++++--- Tests/CliClientTests/Resources/.hparc | 15 ++ Tests/CliClientTests/Resources/config.json | 15 ++ .../Resources/hpa-playbook/config.json | 15 ++ Tests/CliClientTests/Resources/vault.yml | 2 + Tests/CliDocTests/CliDocTests.swift | 156 ------------------ 14 files changed, 354 insertions(+), 196 deletions(-) create mode 100644 .swiftpm/swift-hpa-Package.xctestplan create mode 100644 Tests/CliClientTests/Resources/.hparc create mode 100644 Tests/CliClientTests/Resources/config.json create mode 100644 Tests/CliClientTests/Resources/hpa-playbook/config.json create mode 100644 Tests/CliClientTests/Resources/vault.yml delete mode 100644 Tests/CliDocTests/CliDocTests.swift diff --git a/.gitignore b/.gitignore index 0023a53..3d2eb60 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +.nvim/* +.swiftpm/* diff --git a/.swiftpm/swift-hpa-Package.xctestplan b/.swiftpm/swift-hpa-Package.xctestplan new file mode 100644 index 0000000..16e0ca4 --- /dev/null +++ b/.swiftpm/swift-hpa-Package.xctestplan @@ -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 +} diff --git a/Package.resolved b/Package.resolved index 1c04b30..de39a6f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -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", diff --git a/Package.swift b/Package.swift index 73d6723..2865f51 100644 --- a/Package.swift +++ b/Package.swift @@ -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") + ] + ) ] ) diff --git a/Sources/CliClient/CliClient.swift b/Sources/CliClient/CliClient.swift index 216cefe..9fb1f75 100644 --- a/Sources/CliClient/CliClient.swift +++ b/Sources/CliClient/CliClient.swift @@ -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) - - return try .fromEnv(env) + 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) } diff --git a/Sources/CliClient/Configuration.swift b/Sources/CliClient/Configuration.swift index a029912..5230d5b 100644 --- a/Sources/CliClient/Configuration.swift +++ b/Sources/CliClient/Configuration.swift @@ -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? diff --git a/Sources/CliClient/FileClient.swift b/Sources/CliClient/FileClient.swift index cb5139e..c9cd4ce 100644 --- a/Sources/CliClient/FileClient.swift +++ b/Sources/CliClient/FileClient.swift @@ -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 } } diff --git a/Sources/CliClient/Helpers.swift b/Sources/CliClient/Helpers.swift index 9360830..060edb8 100644 --- a/Sources/CliClient/Helpers.swift +++ b/Sources/CliClient/Helpers.swift @@ -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: "") } diff --git a/Tests/CliClientTests/CliClientTests.swift b/Tests/CliClientTests/CliClientTests.swift index 0597d6a..7d07c89 100644 --- a/Tests/CliClientTests/CliClientTests.swift +++ b/Tests/CliClientTests/CliClientTests.swift @@ -1,32 +1,124 @@ @_spi(Internal) import CliClient import Dependencies +import Foundation import ShellClient import Testing +import TOMLKit -@Test -func testFindConfigPaths() throws { - try withTestLogger(key: "testFindConfigPaths") { - $0.fileClient = .liveValue - } operation: { - @Dependency(\.logger) var logger - let urls = try findConfigurationFiles() - logger.debug("urls: \(urls)") - // #expect(urls.count == 1) - } -} +@Suite("CliClientTests") +struct CliClientTests { -@Test -func loadConfiguration() throws { - try withTestLogger(key: "loadConfiguration", logLevel: .debug) { - $0.cliClient = .liveValue - $0.fileClient = .liveValue - } operation: { - @Dependency(\.cliClient) var client - @Dependency(\.logger) var logger - let config = try client.loadConfiguration() - logger.debug("\(config)") - #expect(config.playbookDir != nil) + @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) + } + } + +// @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( diff --git a/Tests/CliClientTests/Resources/.hparc b/Tests/CliClientTests/Resources/.hparc new file mode 100644 index 0000000..e40fbd5 --- /dev/null +++ b/Tests/CliClientTests/Resources/.hparc @@ -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" +} diff --git a/Tests/CliClientTests/Resources/config.json b/Tests/CliClientTests/Resources/config.json new file mode 100644 index 0000000..a53c938 --- /dev/null +++ b/Tests/CliClientTests/Resources/config.json @@ -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" +} \ No newline at end of file diff --git a/Tests/CliClientTests/Resources/hpa-playbook/config.json b/Tests/CliClientTests/Resources/hpa-playbook/config.json new file mode 100644 index 0000000..a53c938 --- /dev/null +++ b/Tests/CliClientTests/Resources/hpa-playbook/config.json @@ -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" +} \ No newline at end of file diff --git a/Tests/CliClientTests/Resources/vault.yml b/Tests/CliClientTests/Resources/vault.yml new file mode 100644 index 0000000..0c3f712 --- /dev/null +++ b/Tests/CliClientTests/Resources/vault.yml @@ -0,0 +1,2 @@ +--- +# this is just here for testing, doesn't need to be encoded. diff --git a/Tests/CliDocTests/CliDocTests.swift b/Tests/CliDocTests/CliDocTests.swift deleted file mode 100644 index 2dc2221..0000000 --- a/Tests/CliDocTests/CliDocTests.swift +++ /dev/null @@ -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) - } -}