diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..ddf9963 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,20 @@ +--- +name: CI +on: + push: + branches: ["main", "dev"] + pull_request: + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: https://git.housh.dev/actions/setup-just@v1 + - name: Setup QEMU + uses: docker/setup-qemu-action@v3 + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v3 + - name: Run Test + run: just test-docker diff --git a/.gitignore b/.gitignore index 0023a53..6f4f22e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +.nvim/* +.swiftpm/* +./hpa.toml +./Version.* diff --git a/.swiftlint.yml b/.swiftlint.yml index cd25531..213129c 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -2,6 +2,7 @@ disabled_rules: - closing_brace - fuction_body_length - opening_brace + - nesting included: - Sources diff --git a/.swiftpm/swift-hpa-Package.xctestplan b/.swiftpm/swift-hpa-Package.xctestplan new file mode 100644 index 0000000..dc93372 --- /dev/null +++ b/.swiftpm/swift-hpa-Package.xctestplan @@ -0,0 +1,31 @@ +{ + "configurations" : [ + { + "id" : "BE1E3DDC-11A9-41D4-B82D-5EF22CCB79D9", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "CliClientTests", + "name" : "CliClientTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "ConfigurationClientTests", + "name" : "ConfigurationClientTests" + } + } + ], + "version" : 1 +} diff --git a/Package.resolved b/Package.resolved index ffe2e48..08276c9 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8767e1814bf3d2b706110688b4b4d253de070cc5be3f0f91d5790acb7d0b7ad5", + "originHash" : "bc31b11e5e7d488e0a9c1bf91cb572d29f782bfd8e43f44157036f8f3d282893", "pins" : [ { "identity" : "combine-schedulers", @@ -28,6 +28,24 @@ "version" : "1.5.0" } }, + { + "identity" : "swift-cli-doc", + "kind" : "remoteSourceControl", + "location" : "https://git.housh.dev/michael/swift-cli-doc.git", + "state" : { + "revision" : "e524056dc65c5ce7a6a77bdea4e5fa0bf724019b", + "version" : "0.2.0" + } + }, + { + "identity" : "swift-cli-version", + "kind" : "remoteSourceControl", + "location" : "https://github.com/m-housh/swift-cli-version.git", + "state" : { + "revision" : "1885a90f622c91ea9bf7a9b3df82831dece8eb7d", + "version" : "0.1.1" + } + }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", @@ -46,6 +64,15 @@ "version" : "1.3.0" } }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump.git", + "state" : { + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" + } + }, { "identity" : "swift-dependencies", "kind" : "remoteSourceControl", @@ -91,6 +118,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 f00b788..b78ab66 100644 --- a/Package.swift +++ b/Package.swift @@ -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,41 +7,143 @@ let package = Package( platforms: [.macOS(.v14)], products: [ .executable(name: "hpa", targets: ["hpa"]), - .library(name: "CliClient", targets: ["CliClient"]), - .library(name: "CliDoc", targets: ["CliDoc"]) + .library(name: "CodersClient", targets: ["CodersClient"]), + .library(name: "CommandClient", targets: ["CommandClient"]), + .library(name: "ConfigurationClient", targets: ["ConfigurationClient"]), + .library(name: "FileClient", targets: ["FileClient"]), + .library(name: "PlaybookClient", targets: ["PlaybookClient"]), + .library(name: "PandocClient", targets: ["PandocClient"]), + .library(name: "VaultClient", targets: ["VaultClient"]) ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump.git", from: "1.3.3"), .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://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://github.com/m-housh/swift-cli-version.git", from: "0.1.0"), + .package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.5.0") ], targets: [ .executableTarget( name: "hpa", dependencies: [ - "CliClient", - "CliDoc", + "ConfigurationClient", + "FileClient", + "PandocClient", + "PlaybookClient", + "VaultClient", .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "CliDoc", package: "swift-cli-doc"), + .product(name: "CustomDump", package: "swift-custom-dump"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "ShellClient", package: "swift-shell-client") ] ), .target( - name: "CliClient", + name: "CodersClient", dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + .product(name: "TOMLKit", package: "TOMLKit") + ] + ), + .target( + name: "CommandClient", + dependencies: [ + "Constants", .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"), .product(name: "ShellClient", package: "swift-shell-client") ] ), - .testTarget(name: "CliClientTests", dependencies: ["CliClient"]), + .target(name: "Constants"), .target( - name: "CliDoc", + name: "TestSupport", dependencies: [ - .product(name: "ArgumentParser", package: "swift-argument-parser"), + "CommandClient", + .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "ShellClient", package: "swift-shell-client") ] ), - .testTarget(name: "CliDocTests", dependencies: ["CliDoc"]) + .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") + ] + ), + .testTarget( + name: "FileClientTests", + dependencies: ["FileClient", "TestSupport"] + ), + .target( + name: "PandocClient", + dependencies: [ + "CommandClient", + "ConfigurationClient", + "PlaybookClient", + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies") + ] + ), + .testTarget( + name: "PandocClientTests", + dependencies: ["PandocClient", "TestSupport"] + ), + .target( + name: "PlaybookClient", + dependencies: [ + "CommandClient", + "CodersClient", + "ConfigurationClient", + "FileClient", + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + .product(name: "ShellClient", package: "swift-shell-client") + ] + ), + .testTarget( + name: "PlaybookClientTests", + dependencies: [ + "PlaybookClient", + "TestSupport" + ] + ), + .target( + name: "VaultClient", + dependencies: [ + "CommandClient", + "ConfigurationClient", + "FileClient", + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + .product(name: "ShellClient", package: "swift-shell-client") + ] + ), + .testTarget( + name: "VaultClientTests", + dependencies: ["VaultClient", "TestSupport"] + ) ] ) diff --git a/Sources/CliClient/CliClient.swift b/Sources/CliClient/CliClient.swift deleted file mode 100644 index 216cefe..0000000 --- a/Sources/CliClient/CliClient.swift +++ /dev/null @@ -1,124 +0,0 @@ -import Dependencies -import DependenciesMacros -import Foundation -import ShellClient - -public extension DependencyValues { - var cliClient: CliClient { - get { self[CliClient.self] } - set { self[CliClient.self] = newValue } - } -} - -@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( - quiet: Bool, - shell: ShellCommand.Shell, - _ args: [String] - ) async throws { - try await runCommand(args, quiet, shell) - } - - public func runCommand( - quiet: Bool, - shell: ShellCommand.Shell, - _ args: String... - ) async throws { - try await runCommand(args, quiet, shell) - } -} - -extension CliClient: DependencyKey { - - 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)") - try fileClient.loadFile(url, &env, decoder) - - return try .fromEnv(env) - - } runCommand: { args, quiet, shell in - @Dependency(\.asyncShellClient) var shellClient - if !quiet { - try await shellClient.foreground(.init( - shell: shell, - environment: ProcessInfo.processInfo.environment, - in: nil, - args - )) - } else { - try await shellClient.background(.init( - shell: shell, - environment: ProcessInfo.processInfo.environment, - in: nil, - 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) - } - } - - public static var liveValue: CliClient { - .live(env: ProcessInfo.processInfo.environment) - } - - public static let testValue: CliClient = Self() -} - -enum CliClientError: Error { - case fileExistsAtPath(String) - case vaultFileNotFound -} - -private let jsonEncoder: JSONEncoder = { - var encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] - return encoder -}() diff --git a/Sources/CliClient/Configuration.swift b/Sources/CliClient/Configuration.swift deleted file mode 100644 index a029912..0000000 --- a/Sources/CliClient/Configuration.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Dependencies -import Foundation -import ShellClient - -/// Represents the configuration. -public struct Configuration: Codable, 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) } - } -} diff --git a/Sources/CliClient/FileClient.swift b/Sources/CliClient/FileClient.swift deleted file mode 100644 index cb5139e..0000000 --- a/Sources/CliClient/FileClient.swift +++ /dev/null @@ -1,136 +0,0 @@ -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 { - public var loadFile: @Sendable (URL, inout [String: String], JSONDecoder) throws -> Void - public var homeDir: @Sendable () -> URL = { URL(string: "~/")! } - public var isDirectory: @Sendable (URL) -> Bool = { _ in false } - public var isReadable: @Sendable (URL) -> Bool = { _ in false } - public var fileExists: @Sendable (String) -> Bool = { _ in false } - public var findVaultFileInCurrentDirectory: @Sendable () throws -> URL? - public var write: @Sendable (String, Data) throws -> Void -} - -@_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) }, - findVaultFileInCurrentDirectory: { try client.findVaultFileInCurrentDirectory() }, - 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? { - let urls = try fileManager.contentsOfDirectory(at: URL(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 { - @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 - } - - 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]) - } - } - } -} - -private extension Array where Element == URL { - var firstVaultFile: URL? { - first { $0.absoluteString.hasSuffix("vault.yml") } - } -} diff --git a/Sources/CliClient/Helpers.swift b/Sources/CliClient/Helpers.swift deleted file mode 100644 index 9360830..0000000 --- a/Sources/CliClient/Helpers.swift +++ /dev/null @@ -1,109 +0,0 @@ -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 -} - -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 - } -} diff --git a/Sources/CliDoc/ArgumentParserExtensions/Abstract.swift b/Sources/CliDoc/ArgumentParserExtensions/Abstract.swift deleted file mode 100644 index 3549021..0000000 --- a/Sources/CliDoc/ArgumentParserExtensions/Abstract.swift +++ /dev/null @@ -1,16 +0,0 @@ -/// Use the `NodeBuilder` for generating an abstract. -public struct Abstract: NodeRepresentable { - @usableFromInline - let label: any NodeRepresentable - - @inlinable - public init(@NodeBuilder label: () -> any NodeRepresentable) { - self.label = label() - } - - @inlinable - public func render() -> String { - label.render() - } - -} diff --git a/Sources/CliDoc/ArgumentParserExtensions/CommandConfiguration+NodeBuilder.swift b/Sources/CliDoc/ArgumentParserExtensions/CommandConfiguration+NodeBuilder.swift deleted file mode 100644 index acf10e9..0000000 --- a/Sources/CliDoc/ArgumentParserExtensions/CommandConfiguration+NodeBuilder.swift +++ /dev/null @@ -1,33 +0,0 @@ -import ArgumentParser - -public extension CommandConfiguration { - - /// Use `NodeBuilder` syntax for generating the abstract and discussion parameters. - init( - commandName: String? = nil, - abstract: Abstract, - usage: String? = nil, - discussion: Discussion, - version: String = "", - shouldDisplay: Bool = true, - subcommands: [any ParsableCommand.Type] = [], - groupedSubcommands: [CommandGroup] = [], - defaultSubcommand: ParsableCommand.Type? = nil, - helpNames: NameSpecification? = nil, - aliases: [String] = [] - ) { - self.init( - commandName: commandName, - abstract: abstract.render(), - usage: usage, - discussion: discussion.render(), - version: version, - shouldDisplay: shouldDisplay, - subcommands: subcommands, - groupedSubcommands: groupedSubcommands, - defaultSubcommand: defaultSubcommand, - helpNames: helpNames, - aliases: aliases - ) - } -} diff --git a/Sources/CliDoc/ArgumentParserExtensions/Discussion.swift b/Sources/CliDoc/ArgumentParserExtensions/Discussion.swift deleted file mode 100644 index 0717bde..0000000 --- a/Sources/CliDoc/ArgumentParserExtensions/Discussion.swift +++ /dev/null @@ -1,16 +0,0 @@ -/// Use the `NodeBuilder` for generating a discussion. -public struct Discussion: NodeRepresentable { - @usableFromInline - let label: any NodeRepresentable - - @inlinable - public init(@NodeBuilder label: () -> any NodeRepresentable) { - self.label = label() - } - - @inlinable - public func render() -> String { - label.render() - } - -} diff --git a/Sources/CliDoc/Modifiers/AnyModifier.swift b/Sources/CliDoc/Modifiers/AnyModifier.swift deleted file mode 100644 index 8780264..0000000 --- a/Sources/CliDoc/Modifiers/AnyModifier.swift +++ /dev/null @@ -1,14 +0,0 @@ -public struct AnyNodeModifier: NodeModifier { - @usableFromInline - let modifier: any NodeModifier - - @inlinable - public init(_ modifier: N) { - self.modifier = modifier - } - - @inlinable - public func render(_ node: any NodeRepresentable) -> any NodeRepresentable { - modifier.render(node) - } -} diff --git a/Sources/CliDoc/Modifiers/Color.swift b/Sources/CliDoc/Modifiers/Color.swift deleted file mode 100644 index 8cca8e9..0000000 --- a/Sources/CliDoc/Modifiers/Color.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Rainbow - -public extension NodeRepresentable { - - @inlinable - func color(_ color: NamedColor) -> any NodeRepresentable { - modifier(ColorModifier(color: color)) - } - -} - -public extension NodeModifier { - - @inlinable - static func color(_ color: NamedColor) -> Self where Self == AnyNodeModifier { - .init(ColorModifier(color: color)) - } - -} - -@usableFromInline -struct ColorModifier: NodeModifier, @unchecked Sendable { - @usableFromInline - let color: NamedColor - - @usableFromInline - init(color: NamedColor) { - self.color = color - } - - @usableFromInline - func render(_ node: any NodeRepresentable) -> any NodeRepresentable { - node.render().applyingColor(color) - } -} diff --git a/Sources/CliDoc/Modifiers/LabelStyle.swift b/Sources/CliDoc/Modifiers/LabelStyle.swift deleted file mode 100644 index e85fe1b..0000000 --- a/Sources/CliDoc/Modifiers/LabelStyle.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Rainbow - -public extension NodeRepresentable { - - @inlinable - func labelStyle(_ style: any NodeModifier) -> any NodeRepresentable { - return modifier(LabelStyleModifier(style)) - } - - @inlinable - func labelStyle(_ color: NamedColor) -> any NodeRepresentable { - return modifier(LabelStyleModifier(.color(color))) - } - - @inlinable - func labelStyle(_ style: Style) -> any NodeRepresentable { - return modifier(LabelStyleModifier(.style(style))) - } - - @inlinable - func labelStyle(_ styles: Style...) -> any NodeRepresentable { - return modifier(LabelStyleModifier(.style(styles))) - } -} - -@usableFromInline -struct LabelStyleModifier: NodeModifier { - - @usableFromInline - let modifier: any NodeModifier - - @usableFromInline - init(_ modifier: any NodeModifier) { - self.modifier = modifier - } - - @usableFromInline - func render(_ node: any NodeRepresentable) -> any NodeRepresentable { - if let node = node as? LabeledContent { - return LabeledContent(separator: node.separator) { - node.label.modifier(modifier) - } content: { - node.content - } - } - return node - } -} diff --git a/Sources/CliDoc/Modifiers/LabeledContentStyle.swift b/Sources/CliDoc/Modifiers/LabeledContentStyle.swift deleted file mode 100644 index fc0e9ce..0000000 --- a/Sources/CliDoc/Modifiers/LabeledContentStyle.swift +++ /dev/null @@ -1,58 +0,0 @@ -public enum LabelContentStyle: Sendable { - case inline - case `default` - case custom(any NodeRepresentable) -} - -public extension NodeRepresentable { - @inlinable - func labeledContentStyle(_ style: LabelContentStyle) -> any NodeRepresentable { - modifier(LabeledContentStyleModifier(style: style)) - } -} - -@usableFromInline -struct LabeledContentStyleModifier: NodeModifier { - - @usableFromInline - let style: LabelContentStyle - - @usableFromInline - init(style: LabelContentStyle) { - self.style = style - } - - @usableFromInline - func render(_ node: any NodeRepresentable) -> any NodeRepresentable { - if let many = node as? _ManyNode { - var nodes = many.nodes - print("nodes: \(nodes)") - for (idx, node) in nodes.enumerated() { - if let labeled = node as? LabeledContent { - let newNode = labeled.applyingContentStyle(style) - nodes[idx] = newNode - } - } - return _ManyNode(nodes, separator: many.separator) - } else if let labeled = node as? LabeledContent { - return labeled.applyingContentStyle(style) - } - print("no many or labeled nodes found in: \(node)") - return node - } - -} - -private extension LabeledContent { - - func applyingContentStyle(_ style: LabelContentStyle) -> Self { - switch style { - case .inline: - return .init(separator: " ") { label } content: { content } - case .default: - return .init(separator: "\n") { label } content: { content } - case let .custom(custom): - return .init(separator: custom) { label } content: { content } - } - } -} diff --git a/Sources/CliDoc/Modifiers/Repeating.swift b/Sources/CliDoc/Modifiers/Repeating.swift deleted file mode 100644 index dfb39f7..0000000 --- a/Sources/CliDoc/Modifiers/Repeating.swift +++ /dev/null @@ -1,44 +0,0 @@ -public extension NodeRepresentable { - @inlinable - func repeating(_ count: Int, separator: (any NodeRepresentable)? = nil) -> any NodeRepresentable { - modifier(RepeatingNode(count: count, separator: separator)) - } -} - -@usableFromInline -struct RepeatingNode: NodeModifier { - - @usableFromInline - let count: Int - - @usableFromInline - let separator: (any NodeRepresentable)? - - @usableFromInline - init( - count: Int, - separator: (any NodeRepresentable)? - ) { - self.count = count - self.separator = separator - } - - @usableFromInline - func render(_ node: any NodeRepresentable) -> any NodeRepresentable { - let input = node.render() - var output = input - let separator = self.separator != nil - ? self.separator!.render() - : "" - - guard count > 0 else { return output } - - var count = count - 1 - while count > 0 { - output = "\(output)\(separator)\(input)" - count -= 1 - } - - return output - } -} diff --git a/Sources/CliDoc/Modifiers/Style.swift b/Sources/CliDoc/Modifiers/Style.swift deleted file mode 100644 index 922e8a2..0000000 --- a/Sources/CliDoc/Modifiers/Style.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Rainbow - -public extension NodeRepresentable { - @inlinable - func style(_ styles: Style...) -> any NodeRepresentable { - modifier(StyleModifier(styles: styles)) - } - - @inlinable - func style(_ styles: [Style]) -> any NodeRepresentable { - modifier(StyleModifier(styles: styles)) - } -} - -public extension NodeModifier { - @inlinable - static func style(_ styles: Style...) -> Self where Self == AnyNodeModifier { - .init(StyleModifier(styles: styles)) - } - - @inlinable - static func style(_ styles: [Style]) -> Self where Self == AnyNodeModifier { - .init(StyleModifier(styles: styles)) - } -} - -@usableFromInline -struct StyleModifier: NodeModifier, @unchecked Sendable { - @usableFromInline - let styles: [Style] - - @usableFromInline - init(styles: [Style]) { - self.styles = styles - } - - @usableFromInline - func render(_ node: any NodeRepresentable) -> any NodeRepresentable { - styles.reduce(node.render()) { $0.applyingStyle($1) } - } -} diff --git a/Sources/CliDoc/Node.swift b/Sources/CliDoc/Node.swift deleted file mode 100644 index b2a69fd..0000000 --- a/Sources/CliDoc/Node.swift +++ /dev/null @@ -1,26 +0,0 @@ -// swiftlint:disable type_name -public protocol Node: NodeRepresentable { - associatedtype _Body: NodeRepresentable - typealias Body = _Body - - @NodeBuilder - var body: Body { get } -} - -// swiftlint:enable type_name - -public extension Node { - @inlinable - func render() -> String { - body.render() - } -} - -public struct Foo: Node { - - public init() {} - - public var body: some NodeRepresentable { - LabeledContent("Foo") { "Bar" } - } -} diff --git a/Sources/CliDoc/NodeBuilder.swift b/Sources/CliDoc/NodeBuilder.swift deleted file mode 100644 index 5a46ae7..0000000 --- a/Sources/CliDoc/NodeBuilder.swift +++ /dev/null @@ -1,110 +0,0 @@ -@resultBuilder -public enum NodeBuilder { - - public static func buildPartialBlock(first: N) -> N { - first - } - - public static func buildPartialBlock( - accumulated: N0, - next: N1 - ) -> _ManyNode { - .init([accumulated, next], separator: .empty()) - } - - public static func buildArray(_ components: [N]) -> _ManyNode { - .init(components, separator: .empty()) - } - - public static func buildEither( - first component: N - ) -> N { - component - } - - public static func buildEither( - second component: N - ) -> N { - component - } - - public static func buildBlock(_ components: N...) -> _ManyNode { - .init(components) - } - - // This breaks things ?? -// public static func buildExpression(_ expression: N) -> AnyNode { -// expression.eraseToAnyNode() -// } - - public static func buildOptional(_ component: N?) -> _ConditionalNode { - switch component { - case let .some(node): - return .first(node) - case .none: - return .second(.empty()) - } - } - - public static func buildFinalResult(_ component: N) -> N { - component - } - - public static func buildLimitedAvailability(_ component: N) -> N { - component - } - -} - -// These are nodes that are only used by the `NodeBuilder` / internally. - -// swiftlint:disable type_name -public enum _ConditionalNode< - TrueNode: NodeRepresentable, - FalseNode: NodeRepresentable ->: NodeRepresentable { - case first(TrueNode) - case second(FalseNode) - - public func render() -> String { - switch self { - case let .first(node): return node.render() - case let .second(node): return node.render() - } - } -} - -public struct _ManyNode: NodeRepresentable { - @usableFromInline - let nodes: [any NodeRepresentable] - - @usableFromInline - let separator: any NodeRepresentable - - @usableFromInline - init( - _ nodes: [any NodeRepresentable], - separator: any NodeRepresentable = "\n" as any NodeRepresentable - ) { - // Flatten the nodes. - var allNodes = [any NodeRepresentable]() - for node in nodes { - if let many = node as? Self { - allNodes += many.nodes - } else { - allNodes.append(node) - } - } - self.nodes = allNodes - self.separator = separator - } - - @inlinable - public func render() -> String { - nodes.map { $0.render() } - .joined(separator: separator.render()) - } - -} - -// swiftlint:enable type_name diff --git a/Sources/CliDoc/NodeModifier.swift b/Sources/CliDoc/NodeModifier.swift deleted file mode 100644 index 788ca25..0000000 --- a/Sources/CliDoc/NodeModifier.swift +++ /dev/null @@ -1,9 +0,0 @@ -public protocol NodeModifier: Sendable { - func render(_ node: any NodeRepresentable) -> any NodeRepresentable -} - -public extension NodeRepresentable { - func modifier(_ modifier: NodeModifier) -> any NodeRepresentable { - modifier.render(self) - } -} diff --git a/Sources/CliDoc/NodeRepresentable.swift b/Sources/CliDoc/NodeRepresentable.swift deleted file mode 100644 index 47923d8..0000000 --- a/Sources/CliDoc/NodeRepresentable.swift +++ /dev/null @@ -1,3 +0,0 @@ -public protocol NodeRepresentable: Sendable { - func render() -> String -} diff --git a/Sources/CliDoc/Nodes/AnyNode.swift b/Sources/CliDoc/Nodes/AnyNode.swift deleted file mode 100644 index f20132f..0000000 --- a/Sources/CliDoc/Nodes/AnyNode.swift +++ /dev/null @@ -1,40 +0,0 @@ -// TODO: This doesn't seem correct, maybe remove this type. -public struct AnyNode: NodeRepresentable { - - @usableFromInline - let makeBody: @Sendable () -> String - -// @usableFromInline -// let node: Value - - @inlinable - public init(@NodeBuilder _ build: () -> N) { - self.makeBody = build().render - } - - @inlinable - public init(_ node: N) { - self.makeBody = node.render - } - - public func render() -> String { - makeBody() - } -// -// public var body: some NodeRepresentable { -// node -// } - -// @inlinable -// public func render() -> String { -// node.render() -// } -} - -public extension NodeRepresentable { - - @inlinable - func eraseToAnyNode() -> AnyNode { - .init(self) - } -} diff --git a/Sources/CliDoc/Nodes/Group.swift b/Sources/CliDoc/Nodes/Group.swift deleted file mode 100644 index a15aeaa..0000000 --- a/Sources/CliDoc/Nodes/Group.swift +++ /dev/null @@ -1,63 +0,0 @@ -public struct Group: NodeRepresentable { - @usableFromInline - let node: any NodeRepresentable - - @inlinable - public init( - separator: any NodeRepresentable = " ", - @NodeBuilder node: () -> any NodeRepresentable - ) { - if let many = node as? _ManyNode { - self.node = _ManyNode(many.nodes, separator: separator) - } else { - self.node = node() - } - } - -// @inlinable -// public init( -// separator: any NodeRepresentable, -// @NodeBuilder _ build: () -> any NodeRepresentable -// ) { -// self.init(separator: separator.eraseToAnyNode(), node: build()) -// } - -// @inlinable -// public init( -// separator: String = " ", -// @NodeBuilder _ build: () -> any NodeRepresentable -// ) { -// self.init(separator: separator.eraseToAnyNode(), node: build()) -// } - - @inlinable - public func render() -> String { - node.render() - } -} - -public struct Group2: Node { - - @usableFromInline - let content: Content - - @usableFromInline - let separator: any NodeRepresentable - - @inlinable - public init( - separator: any NodeRepresentable = " ", - @NodeBuilder _ content: () -> Content - ) { - self.content = content() - self.separator = separator - } - - @inlinable - public var body: some NodeRepresentable { - guard let content = content as? _ManyNode else { - return content.eraseToAnyNode() - } - return _ManyNode(content.nodes, separator: separator).eraseToAnyNode() - } -} diff --git a/Sources/CliDoc/Nodes/Header.swift b/Sources/CliDoc/Nodes/Header.swift deleted file mode 100644 index b60937e..0000000 --- a/Sources/CliDoc/Nodes/Header.swift +++ /dev/null @@ -1,45 +0,0 @@ -@preconcurrency import Rainbow - -public struct Header: NodeRepresentable, @unchecked Sendable { - - @usableFromInline - let node: any NodeRepresentable - - @usableFromInline - let color: NamedColor? - - @usableFromInline - let styles: [Style]? - - @inlinable - public init( - @NodeBuilder _ build: () -> any NodeRepresentable - ) { - self.node = build() - self.color = nil - self.styles = nil - } - - @inlinable - public init( - _ text: String, - color: NamedColor? = .yellow, - styles: [Style]? = [.bold] - ) { - self.color = color - self.node = Text(text) - self.styles = styles - } - - @inlinable - public func render() -> String { - var node = node - if let color { - node = node.color(color) - } - if let styles { - node = node.style(styles) - } - return node.render() - } -} diff --git a/Sources/CliDoc/Nodes/LabeledContent.swift b/Sources/CliDoc/Nodes/LabeledContent.swift deleted file mode 100644 index 289abb2..0000000 --- a/Sources/CliDoc/Nodes/LabeledContent.swift +++ /dev/null @@ -1,75 +0,0 @@ -public struct LabeledContent: NodeRepresentable { - - @usableFromInline - let label: any NodeRepresentable - - @usableFromInline - let content: any NodeRepresentable - - @usableFromInline - let separator: any NodeRepresentable - - @inlinable - public init( - separator: (any NodeRepresentable)? = nil, - @NodeBuilder label: () -> any NodeRepresentable, - @NodeBuilder content: () -> any NodeRepresentable - ) { - self.separator = separator ?? "\n" - self.label = label() - self.content = content() - } - - @inlinable - public func render() -> String { - Group(separator: separator) { - label - content - }.render() - } -} - -public extension LabeledContent { - - @inlinable - init( - _ label: String, - separator: (any NodeRepresentable)? = nil, - @NodeBuilder content: () -> any NodeRepresentable - ) { - self.init(separator: separator) { - Text(label) - } content: { - content() - } - } -} - -public struct LabeledContent2: Node { - - @usableFromInline - let separator: any NodeRepresentable - - @usableFromInline - let label: Label - - @usableFromInline - let content: Content - - public init( - separator: any NodeRepresentable = " ", - @NodeBuilder label: () -> Label, - @NodeBuilder content: () -> Content - ) { - self.separator = separator - self.label = label() - self.content = content() - } - - public var body: some NodeRepresentable { - Group2(separator: separator) { - label - content - } - } -} diff --git a/Sources/CliDoc/Nodes/Section.swift b/Sources/CliDoc/Nodes/Section.swift deleted file mode 100644 index b761ca5..0000000 --- a/Sources/CliDoc/Nodes/Section.swift +++ /dev/null @@ -1,30 +0,0 @@ -public struct Section: NodeRepresentable { - - @usableFromInline - let content: any NodeRepresentable - - public init(@NodeBuilder content: () -> any NodeRepresentable) { - self.content = content() - } - - public func render() -> String { - guard let many = content as? _ManyNode else { - return content.render() + "\n\n" - } - - let node = _ManyNode(many.nodes, separator: "\n\n") - return node.render() - -// var output = "" -// let lastIndex = many.nodes.count - 1 -// -// for (idx, content) in many.nodes.enumerated() { -// if idx != lastIndex { -// output += content.render() + "\n\n" -// } else { -// output += content.render() + "\n" -// } -// } -// return output - } -} diff --git a/Sources/CliDoc/Nodes/ShellCommand.swift b/Sources/CliDoc/Nodes/ShellCommand.swift deleted file mode 100644 index 317acd9..0000000 --- a/Sources/CliDoc/Nodes/ShellCommand.swift +++ /dev/null @@ -1,41 +0,0 @@ -public struct ShellCommand: NodeRepresentable { - - @usableFromInline - let symbol: any NodeRepresentable - - @usableFromInline - let command: any NodeRepresentable - - @inlinable - public init( - @NodeBuilder symbol: () -> any NodeRepresentable, - @NodeBuilder command: () -> any NodeRepresentable - ) { - self.symbol = symbol() - self.command = command() - } - - @inlinable - public init( - symbol: String = " $", - @NodeBuilder command: () -> any NodeRepresentable - ) { - self.init { symbol } command: { command() } - } - - @inlinable - public init( - symbol: String = " $", - command: String - ) { - self.init { symbol } command: { command } - } - - @inlinable - public func render() -> String { - Group(separator: " ") { - symbol - command - }.render() - } -} diff --git a/Sources/CliDoc/Nodes/String+NodeRepresentable.swift b/Sources/CliDoc/Nodes/String+NodeRepresentable.swift deleted file mode 100644 index 139d280..0000000 --- a/Sources/CliDoc/Nodes/String+NodeRepresentable.swift +++ /dev/null @@ -1,13 +0,0 @@ -extension String: NodeRepresentable { - @inlinable - public func render() -> String { - self - } -} - -extension String: Node { - - public var body: some NodeRepresentable { - self - } -} diff --git a/Sources/CliDoc/Nodes/Text.swift b/Sources/CliDoc/Nodes/Text.swift deleted file mode 100644 index 8ea863c..0000000 --- a/Sources/CliDoc/Nodes/Text.swift +++ /dev/null @@ -1,32 +0,0 @@ -@preconcurrency import Rainbow - -public struct Text: Node { - @usableFromInline - let text: String - - @inlinable - public init(_ text: String) { - self.text = text - } - - @inlinable - public var body: some NodeRepresentable { - text - } -} - -public extension NodeRepresentable { - - static func empty() -> Self where Self == Text { - Text("") - } - - static func newLine(count: Int = 1) -> Self where Self == AnyNode { - "\n".repeating(count).eraseToAnyNode() - } - - static func space(count: Int = 1) -> Self where Self == AnyNode { - " ".repeating(count).eraseToAnyNode() - } - -} diff --git a/Sources/CodersClient/Coders.swift b/Sources/CodersClient/Coders.swift new file mode 100644 index 0000000..f861c69 --- /dev/null +++ b/Sources/CodersClient/Coders.swift @@ -0,0 +1,41 @@ +import Dependencies +import DependenciesMacros +import Foundation +import TOMLKit + +public extension DependencyValues { + + /// Holds onto decoders and encoders for json and toml files. + 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: { defaultJsonEncoder }, + tomlDecoder: { TOMLDecoder() }, + tomlEncoder: { TOMLEncoder() } + ) + } +} + +private let defaultJsonEncoder: JSONEncoder = { + var encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] + return encoder +}() diff --git a/Sources/CommandClient/CommandClient.swift b/Sources/CommandClient/CommandClient.swift new file mode 100644 index 0000000..f283ff8 --- /dev/null +++ b/Sources/CommandClient/CommandClient.swift @@ -0,0 +1,233 @@ +import Constants +import Dependencies +import DependenciesMacros +import Foundation +import ShellClient + +public extension DependencyValues { + + /// Runs shell commands. + var commandClient: CommandClient { + get { self[CommandClient.self] } + set { self[CommandClient.self] = newValue } + } +} + +@DependencyClient +public struct CommandClient: Sendable { + + /// Runs a shell command. + public var runCommand: @Sendable (RunCommandOptions) async throws -> Void + + /// Runs a shell command, sets up logging, and returns an output value. + /// + /// This is useful when you need to give some output value to the caller, + /// generally for the ability of that output to be piped into other commands. + /// + @discardableResult + public func run( + logging logginOptions: LoggingOptions, + quiet: Bool = false, + shell: String? = nil, + in workingDirectory: String? = nil, + makeArguments: @Sendable @escaping () async throws -> ([String], T) + ) async throws -> T { + try await logginOptions.withLogger { + let (arguments, returnValue) = try await makeArguments() + try await self.run( + quiet: quiet, + shell: shell, + arguments + ) + return returnValue + } + } + + /// Runs a shell command and sets up logging. + public func run( + logging logginOptions: LoggingOptions, + quiet: Bool = false, + shell: String? = nil, + in workingDirectory: String? = nil, + makeArguments: @Sendable @escaping () async throws -> [String] + ) async throws { + try await run( + logging: logginOptions, + quiet: quiet, + shell: shell, + in: workingDirectory, + makeArguments: { try await (makeArguments(), ()) } + ) + } + + /// Runs a shell command. + public func run( + quiet: Bool = false, + shell: String? = nil, + in workingDirectory: String? = nil, + _ arguments: [String] + ) async throws { + try await runCommand(.init( + arguments: arguments, + quiet: quiet, + shell: shell, + workingDirectory: workingDirectory + )) + } + + /// Runs a shell command. + public func run( + quiet: Bool = false, + shell: String? = nil, + in workingDirectory: String? = nil, + _ arguments: String... + ) async throws { + try await run( + quiet: quiet, + shell: shell, + in: workingDirectory, + arguments + ) + } + + public struct RunCommandOptions: Sendable, Equatable { + public let arguments: [String] + public let quiet: Bool + public let shell: String? + public let workingDirectory: String? + + public init( + arguments: [String], + quiet: Bool, + shell: String? = nil, + workingDirectory: String? = nil + ) { + self.arguments = arguments + self.quiet = quiet + self.shell = shell + self.workingDirectory = workingDirectory + } + } +} + +/// Represents logger setup options used when running commands. +/// +/// This set's up metadata tags and keys appropriately for the command being run, +/// which can aid in debugging. +/// +public struct LoggingOptions: Equatable, Sendable { + + /// The command name / key that the logger is running for. + public let commandName: String + + /// The log level. + public let logLevel: Logger.Level + + /// Create the logging options. + /// + /// - Parameters: + /// - commandName: The command name / key that the logger is running for. + /// - logLevel: The log level. + public init(commandName: String, logLevel: Logger.Level) { + self.commandName = commandName + self.logLevel = logLevel + } + + /// Perform an operation with a setup logger. + /// + /// - Parameters: + /// - operation: The operation to perform with the setup logger. + @discardableResult + public func withLogger( + operation: @Sendable @escaping () async throws -> T + ) async rethrows -> T { + try await withDependencies { + $0.logger = .init(label: "\(Constants.executableName)") + $0.logger.logLevel = logLevel + $0.logger[metadataKey: "command"] = "\(commandName.blue)" + } operation: { + try await operation() + } + } +} + +extension CommandClient: DependencyKey { + + public static let testValue: CommandClient = Self() + + public static func live( + environment: [String: String] + ) -> CommandClient { + .init { options in + @Dependency(\.asyncShellClient) var shellClient + if !options.quiet { + try await shellClient.foreground(.init( + shell: .init(options.shell), + environment: environment, + in: options.workingDirectory, + options.arguments + )) + } else { + try await shellClient.background(.init( + shell: .init(options.shell), + environment: environment, + in: options.workingDirectory, + options.arguments + )) + } + } + } + + public static var liveValue: CommandClient { + .live(environment: ProcessInfo.processInfo.environment) + } +} + +@_spi(Internal) +public extension CommandClient { + + /// Create a command client that can capture the arguments / options. + /// + /// This is used for testing. + static func capturing(_ client: CapturingClient) -> Self { + .init { options in + await client.set(options) + } + } + + /// Captures the arguments / options passed into the command client's run commands. + /// + @dynamicMemberLookup + actor CapturingClient: Sendable { + public private(set) var options: RunCommandOptions? + + public init() {} + + public func set( + _ options: RunCommandOptions + ) { + self.options = options + } + + public subscript(dynamicMember keyPath: KeyPath) -> T? { + options?[keyPath: keyPath] + } + } +} + +extension ShellCommand.Shell: @retroactive @unchecked Sendable {} + +extension ShellCommand.Shell { + init(_ path: String?) { + if let path { + self = .custom(path: path, useDashC: true) + } else { + #if os(macOS) + self = .zsh(useDashC: true) + #else + // Generally we're in a docker container when this occurs, which does not have `zsh` installed. + self = .sh(useDashC: true) + #endif + } + } +} diff --git a/Sources/ConfigurationClient/Configuration.swift b/Sources/ConfigurationClient/Configuration.swift new file mode 100644 index 0000000..385f4fa --- /dev/null +++ b/Sources/ConfigurationClient/Configuration.swift @@ -0,0 +1,224 @@ +import Foundation + +// NOTE: When adding items, then the 'hpa.toml' resource file needs to be updated. + +/// Represents configurable settings for the application. +public struct Configuration: Codable, Equatable, Sendable { + + /// Default arguments / options that can get passed into + /// ansible-playbook commands. + public let args: [String]? + + /// Whether to use the vault arguments as defaults that get passed into + /// the ansible-playbook commands. + public let useVaultArgs: Bool + + /// Configuration for when generating files from templated directories. + public let generate: Generate? + + /// Configuration of the ansible-playbook, these are more for developing the + /// playbook locally and generally not needed by the user. + public let playbook: Playbook? + + /// Template configuration options. + public let template: Template + + /// Ansible-vault configuration options. + public let vault: Vault + + public init( + args: [String]? = nil, + useVaultArgs: Bool = true, + generate: Generate? = nil, + playbook: Playbook? = nil, + template: Template = .init(), + vault: Vault = .init() + ) { + self.args = args + self.useVaultArgs = useVaultArgs + self.generate = generate + self.playbook = playbook + self.template = template + self.vault = vault + } + + public static var mock: Self { + .init( + args: [], + useVaultArgs: true, + playbook: nil, + template: .mock, + vault: .mock + ) + } + + /// Configuration options for running `pandoc` commands that generate files from + /// a templated directory. + /// + /// ## NOTE: Most of these can also be set by the template repository variables. + /// + /// + public struct Generate: Codable, Equatable, Sendable { + + /// Specifiy the name of the build directory, generally `.build`. + public let buildDirectory: String? + + /// Specifiy the files used in the `pandoc` command to generate a final output file. + /// + /// - SeeAlso: `pandoc --help` + public let files: [String]? + + /// Specifiy the files used in the `pandoc` command to include in the header. + /// + /// - SeeAlso: `pandoc --help` + public let includeInHeader: [String]? + + /// The default name of the output file, this does not require the file extension as we will + /// add it based on the command that is called. Generally this is 'Report'. + /// + public let outputFileName: String? + + /// The default pdf engine to use when generating pdf files using `pandoc`. Generally + /// this is 'xelatex'. + public let pdfEngine: String? + + public init( + buildDirectory: String? = nil, + files: [String]? = nil, + includeInHeader: [String]? = nil, + outputFileName: String? = nil, + pdfEngine: String? = nil + ) { + self.buildDirectory = buildDirectory + self.files = files + self.includeInHeader = includeInHeader + self.outputFileName = outputFileName + self.pdfEngine = pdfEngine + } + + /// Represents the default configuration for generating files using `pandoc`. + public static let `default` = Self.mock + + /// Represents mock configuration for generating files using `pandoc`. + public static var mock: Self { + .init( + buildDirectory: ".build", + files: ["Report.md", "Definitions.md"], + includeInHeader: ["head.tex", "footer.tex"], + outputFileName: "Report", + pdfEngine: "xelatex" + ) + } + } + + /// Configuration options for the ansible-hpa-playbook. The playbook is + /// the primary driver of templating files, generating repository templates, and building + /// projects. + /// + /// ## NOTE: These are generally only used for local development of the playbook. + /// + /// + public struct Playbook: Codable, Equatable, Sendable { + + /// The directory location of the ansible playbook. + public let directory: String? + + /// The inventory file name / location. + public let inventory: String? + + /// The playbook version, branch, or tag. + public let version: String? + + public init( + directory: String? = nil, + inventory: String? = nil, + version: String? = nil + ) { + self.directory = directory + self.inventory = inventory + self.version = version + } + + public static var mock: Self { .init() } + } + + /// Configuration settings for the user's template repository or directory. + /// + /// A template is what is used to create projects for a user. Generally they setup + /// their own template by customizing the default template to their needs. This template + /// can then be stored as a git repository or on the local file system. + /// + /// The template will hold variables and files that are used to generate the final output + /// files using `pandoc`. Generally the template will hold files that may only need setup once, + /// such as header files, footer files, and definitions. + /// + /// The project directory contains dynamic variables / files that need edited in order + /// to generate the final output. This allows the project directory to remain smaller so the user + /// can more easily focus on the tasks required to generate the final output files. + public struct Template: Codable, Equatable, Sendable { + + /// A url of the template when it is a remote git repository. + public let url: String? + + /// The version, branch, or tag of the remote repository. + public let version: String? + + /// The local directory that contains the template on the local file system. + 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" + ) + } + } + + /// Configuration for `ansible-vault` commands. Ansible vault is used to encrypt and + /// decrypt sensitive data. This allows for variables, such as customer name and address + /// to be stored along with the project in an encrypted file so that it is safer to store them. + /// + /// These may also be used in general `ansible-playbook` commands if the `useVaultArgs` is set + /// to `true` in a users configuration. + /// + public struct Vault: Codable, Equatable, Sendable { + + /// A list of arguments / options that get passed to the `ansible-vault` command. + /// + /// - SeeAlso: `ansible-vault --help` + public let args: [String]? + + /// An id that is used during encrypting `ansible-vault` files. + /// + /// - SeeAlso: `ansible-vault encrypt --help` + 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" + ) + } + } +} diff --git a/Sources/ConfigurationClient/ConfigurationClient.swift b/Sources/ConfigurationClient/ConfigurationClient.swift new file mode 100644 index 0000000..e3bc307 --- /dev/null +++ b/Sources/ConfigurationClient/ConfigurationClient.swift @@ -0,0 +1,349 @@ +import CodersClient +import Dependencies +import DependenciesMacros +import FileClient +import Foundation +import ShellClient + +public extension DependencyValues { + + /// Interacts with the user's configuration. + var configurationClient: ConfigurationClient { + get { self[ConfigurationClient.self] } + set { self[ConfigurationClient.self] = newValue } + } +} + +/// Represents actions that can be taken on user's configuration files. +/// +/// +@DependencyClient +public struct ConfigurationClient: Sendable { + + /// Find the user's configuration, searches in the current directory and default + /// locations where configuration can be stored. An error is thrown if no configuration + /// is found. + public var find: @Sendable () async throws -> File + + /// Generate a configuration file for the user. + public var generate: @Sendable (GenerateOptions) async throws -> String + + /// Load a configuration file from the given file location. If the file is + /// not provided then we return an empty configuraion item. + public var load: @Sendable (File?) async throws -> Configuration + + /// Write the configuration to the given file, optionally forcing an overwrite of + /// the file. + /// + /// ## NOTE: This uses the `fileClient.write` under the hood, so if you need to control + /// what happens during tests, then you can customize the behavior of the fileClient. + /// + public var write: @Sendable (WriteOptions) async throws -> Void + + /// Find the user's configuration and load it. + public func findAndLoad() async throws -> Configuration { + let file = try? await find() + return try await load(file) + } + + /// Write the configuration to the given file, optionally forcing an overwrite of + /// the file. If a file already exists and force is not supplied then we will create + /// a backup of the existing file. + /// + /// ## NOTE: This uses the `fileClient.write` under the hood, so if you need to control + /// what happens during tests, then you can customize the behavior of the fileClient. + /// + /// - Parameters: + /// - configuration: The configuration to save. + /// - file: The file location and type to save. + /// - force: Force overwritting if a file already exists. + public func write( + _ configuration: Configuration, + to file: File, + force: Bool = false + ) async throws { + try await write(.init(configuration, to: file, force: force)) + } + + /// Represents the options to generate a configuration file for a user. + public struct GenerateOptions: Equatable, Sendable { + + /// Force generation, overwritting existing file if it exists. + public let force: Bool + + /// Generate a `json` file instead of the default `toml` file. + public let json: Bool + + /// The path to generate a file in. + public let path: Path? + + public init( + force: Bool = false, + json: Bool = false, + path: Path? = nil + ) { + self.force = force + self.json = json + self.path = path + } + + /// Represents the path option for generating a configuration file for a user. + /// + /// This can either be a full file path or a directory. If a directory is supplied + /// then we will use the default file name of 'config' and add the extension dependning + /// on if the caller wants a `json` or `toml` file. + public enum Path: Equatable, Sendable { + case file(File) + case directory(String) + } + } + + /// Represents the options required to write a configuration file. + public struct WriteOptions: Equatable, Sendable { + + /// The configuration to wrtie to the file path. + public let configuration: Configuration + + /// The file path to write the configuration to. + public let file: File + + /// Force overwritting an existing file, if it exists. + public let force: Bool + + public init( + _ configuration: Configuration, + to file: File, + force: Bool + ) { + self.configuration = configuration + self.file = file + self.force = force + } + } +} + +extension ConfigurationClient: DependencyKey { + public static var testValue: Self { Self() } + + public static func live(environment: [String: String]) -> Self { + let liveClient = LiveConfigurationClient(environment: environment) + return .init( + find: { try await liveClient.find() }, + generate: { try await liveClient.generate($0) }, + load: { try await liveClient.load(file: $0) }, + write: { try await liveClient.write($0) } + ) + } + + public static var liveValue: Self { + .live(environment: ProcessInfo.processInfo.environment) + } + + @_spi(Internal) + public static func mock(_ configuration: Configuration = .mock) -> Self { + .init( + find: { throw MockFindError() }, + generate: Self().generate, + load: { _ in configuration }, + write: Self().write + ) + } +} + +struct LiveConfigurationClient { + + private let environment: [String: String] + + @Dependency(\.coders) var coders + @Dependency(\.fileClient) var fileManager + @Dependency(\.logger) var logger + + let validFileNames = [ + "config.json", "config.toml", + ".hparc.json", ".hparc.toml" + ] + + init(environment: [String: String]) { + self.environment = environment + } + + func find() async throws -> File { + logger.debug("Begin find configuration.") + + if let pwd = environment["PWD"], + let file = await findInDirectory(pwd) + { + logger.debug("Found configuration in pwd: \(file.path)") + return file + } + + if let configHome = environment.hpaConfigHome, + let file = await findInDirectory(configHome) + { + logger.debug("Found configuration in config home env var: \(file.path)") + return file + } + + if let configFile = environment.hpaConfigFile, + isReadable(configFile), + let file = File(configFile) + { + logger.debug("Found configuration in config file env var: \(file.path)") + return file + } + + if let file = await findInDirectory(fileManager.homeDirectory()) { + logger.debug("Found configuration in home directory: \(file.path)") + return file + } + + if let file = await findInDirectory("\(environment.xdgConfigHome)/\(HPAKey.configDirName)") { + logger.debug("Found configuration in xdg config directory: \(file.path)") + return file + } + + throw ConfigurationError.configurationNotFound + } + + func generate(_ options: ConfigurationClient.GenerateOptions) async throws -> String { + @Dependency(\.logger) var logger + + let file: File + + if let path = options.path { + switch path { + case let .file(requestedFile): + file = requestedFile + case let .directory(directory): + file = .init("\(directory)/\(HPAKey.defaultFileNameWithoutExtension).\(options.json ? "json" : "toml")")! + } + } else { + let configDir = "\(environment.xdgConfigHome)/\(HPAKey.configDirName)" + file = .init("\(configDir)/\(HPAKey.defaultFileName)")! + } + + logger.debug("Begin generating configuration: \(file.path), force: \(options.force)") + + let expandedPath = file.path.replacingOccurrences( + of: "~", + with: fileManager.homeDirectory().cleanFilePath + ) + + let fileUrl = URL(filePath: expandedPath) + + let exists = fileManager.fileExists(fileUrl) + + if !options.force, exists { + try await createBackup(file.url) + } + + let fileDirectory = fileUrl.deletingLastPathComponent() + let directoryExists = try await fileManager.isDirectory(fileDirectory) + + if !directoryExists { + logger.debug("Creating directory at: \(fileDirectory.cleanFilePath)") + try await fileManager.createDirectory(fileDirectory) + } + + // TODO: The hpa file needs to be copied somewhere on the system during install and + // not use bundle, as it only works if the tool was built on the users system. + if case .toml = file { + // In the case of toml, we copy the internal resource that includes + // usage comments in the file. + guard let resourceFile = Bundle.module.url( + forResource: HPAKey.resourceFileName, + withExtension: HPAKey.resourceFileExtension + ) else { + throw ConfigurationError.resourceNotFound + } + + try await fileManager.copy(resourceFile, fileUrl) + } else { + // Json does not allow comments, so we write the mock configuration + // to the file path. + try await write(.init(.mock, to: File(fileUrl)!, force: options.force)) + } + + return fileUrl.cleanFilePath + } + + func load(file: File?) async throws -> Configuration { + guard let file else { return .init() } + let data = try await fileManager.load(file.url) + + switch file { + case .json: + return try coders.jsonDecoder().decode(Configuration.self, from: data) + case .toml: + guard let string = String(data: data, encoding: .utf8) else { + throw ConfigurationError.decodingError + } + return try coders.tomlDecoder().decode(Configuration.self, from: string) + } + } + + func write( + _ options: ConfigurationClient.WriteOptions + ) async throws { + let configuration = options.configuration + let file = options.file + let force = options.force + + let exists = fileManager.fileExists(file.url) + + if !force, exists { + try await createBackup(file.url) + } + + let data: Data + + switch file { + case .json: + data = try coders.jsonEncoder().encode(configuration) + case .toml: + let string = try coders.tomlEncoder().encode(configuration) + data = Data(string.utf8) + } + + try await fileManager.write(data, file.url) + } + + private func createBackup(_ url: URL) async throws { + let backupUrl = url.appendingPathExtension("back") + logger.warning("File exists, creating a backup of the existing file at: \(backupUrl.cleanFilePath)") + try await fileManager.copy(url, backupUrl) + try await fileManager.delete(url) + } + + private func findInDirectory(_ directory: URL) async -> File? { + for file in validFileNames { + let url = directory.appending(path: file) + if isReadable(url), let file = File(url) { + return file + } + } + return nil + } + + private func findInDirectory(_ directory: String) async -> File? { + await findInDirectory(URL(filePath: directory)) + } + + private func isReadable(_ file: URL) -> Bool { + FileManager.default.isReadableFile(atPath: file.cleanFilePath) + } + + private func isReadable(_ file: String) -> Bool { + isReadable(URL(filePath: file)) + } + +} + +enum ConfigurationError: Error { + case configurationNotFound + case resourceNotFound + case decodingError + case fileExists(path: String) +} + +struct MockFindError: Error {} diff --git a/Sources/ConfigurationClient/Constants.swift b/Sources/ConfigurationClient/Constants.swift new file mode 100644 index 0000000..9030021 --- /dev/null +++ b/Sources/ConfigurationClient/Constants.swift @@ -0,0 +1,32 @@ +/// Represents keys in the environment that can be used to locate a user's +/// configuration file. +@_spi(Internal) +public enum EnvironmentKey { + static let xdgConfigHome = "XDG_CONFIG_HOME" + static let hpaConfigHome = "HPA_CONFIG_HOME" + static let hpaConfigFile = "HPA_CONFIG_FILE" +} + +/// Represents keys that are used internally for directory names, file names, etc. +@_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" + public static let defaultFileNameWithoutExtension = "config" +} + +extension [String: String] { + var xdgConfigHome: String { + self[EnvironmentKey.xdgConfigHome] ?? "~/.config" + } + + var hpaConfigHome: String? { + self[EnvironmentKey.hpaConfigHome] + } + + var hpaConfigFile: String? { + self[EnvironmentKey.hpaConfigFile] + } +} diff --git a/Sources/ConfigurationClient/File.swift b/Sources/ConfigurationClient/File.swift new file mode 100644 index 0000000..03ee3f3 --- /dev/null +++ b/Sources/ConfigurationClient/File.swift @@ -0,0 +1,65 @@ +import FileClient +import Foundation + +/// Represents a file location and type on disk for a configuration file. +/// +/// Currently the supported formats are `json` or `toml`. Toml is the default / +/// preferred format because it allows comments in the file and is easy to understand. +/// +public enum File: Equatable, Sendable { + + case json(URL) + case toml(URL) + + /// Attempts to create a file with the given url. + /// + /// ## NOTE: There are no checks on if a file / path actually exists or not. + /// + /// If the file does not have a suffix of `json` or `toml` then + /// we will return `nil`. + public init?(_ url: URL) { + if url.cleanFilePath.hasSuffix("json") { + self = .json(url) + } else if url.cleanFilePath.hasSuffix("toml") { + self = .toml(url) + } else { + return nil + } + } + + /// Attempts to create a file with the given path. + /// + /// ## NOTE: There are no checks on if a file / path actually exists or not. + /// + /// If the file does not have a suffix of `json` or `toml` then + /// we will return `nil`. + public init?(_ path: String) { + self.init(URL(filePath: path)) + } + + /// Get the url of the file. + public var url: URL { + switch self { + case let .json(url): return url + case let .toml(url): return url + } + } + + /// Get the path string of the file. + public var path: String { + url.cleanFilePath + } + + /// Represents the default file path for a user's configuration. + /// + /// Which is `~/.config/hpa/config.toml` + public static var `default`: Self { + let fileUrl = FileManager.default + .homeDirectoryForCurrentUser + .appending(path: ".config") + .appending(path: HPAKey.configDirName) + .appending(path: HPAKey.defaultFileName) + + return .toml(fileUrl) + } +} diff --git a/Sources/ConfigurationClient/Resources/hpa.toml b/Sources/ConfigurationClient/Resources/hpa.toml new file mode 100644 index 0000000..93bd994 --- /dev/null +++ b/Sources/ConfigurationClient/Resources/hpa.toml @@ -0,0 +1,72 @@ +# NOTE: +# Configuration settings for the `hpa` command line tool. +# You can delete settings that are not applicable to your use case. + +# Default arguments / options that get passed into `ansible-playbook` commands. +# WARNING: Do not put arguments / options that contain spaces in the same string, +# they should be separate strings, for example do not do something like +# ['--tags debug'], instead use ['--tags', 'debug']. +# +args = [] + +# Set to true if you want to pass the vault args to `ansible-playbook` commands. +useVaultArgs = true + +# NOTE: +# Configuration for running the generate command(s). This allows custimizations +# to the files that get used to generate the final output (generally a pdf). +# See `pandoc --help`. Below are the defaults that get used, which only need +# adjusted if your template does not follow the default template design or if +# you add extra files to your template that need to be included in the final +# output. Also be aware that any of the files specified in the `files` or +# `includeInHeader` options, need to be inside the `buildDirectory` when generating +# the final output file. + +# [generate] +# this relative to the project directory. +# buildDirectory = '.build' +# pdfEngine = 'xelatex' +# includeInHeader = [ +# 'head.tex', +# 'footer.tex' +# ] +# files = [ +# 'Report.md', +# 'Definitions.md' +# ] +# outputFileName = 'Report' + +# NOTE: +# These are more for local development of the ansible playbook and should not be needed +# in most cases. Uncomment the lines if you want to customize the playbook and use it +# instead of the provided / default playbook. + +#[playbook] +#directory = '/path/to/local/playbook-directory' +#inventory = '/path/to/local/inventory.ini' +#version = 'main' + +# NOTE: +# These are to declare where your template files are either on your local system or +# a remote git repository. +[template] +# The directory path on your local system to the template files. +directory = '/path/to/local/template-directory' + +# The url to a git repository that contains your template files. +url = 'https://git.example.com/consult-template.git' + +# The version, tag, branch, or sha of the template files to clone from the remote +# template repository. In general it is best practice to use a version instead of a +# branch. +version = '1.0.0' + +# NOTE: +# Holds settings for `ansible-vault` commands. +[vault] +# Arguments to pass to commands that use `ansible-vault`, such as encrypting or decrypting +# files. +args = [ '--vault-id=myId@$SCRIPTS/vault-gopass-client' ] + +# An id to use when encrypting `ansible-vault` files. +encryptId = 'myId' diff --git a/Sources/Constants/Constants.swift b/Sources/Constants/Constants.swift new file mode 100644 index 0000000..9889274 --- /dev/null +++ b/Sources/Constants/Constants.swift @@ -0,0 +1,3 @@ +public enum Constants { + public static let executableName = "hpa" +} diff --git a/Sources/FileClient/FileClient.swift b/Sources/FileClient/FileClient.swift new file mode 100644 index 0000000..6619b8a --- /dev/null +++ b/Sources/FileClient/FileClient.swift @@ -0,0 +1,151 @@ +import Dependencies +import DependenciesMacros +import Foundation + +public extension DependencyValues { + /// Represents interactions with the file system. + /// + var fileClient: FileClient { + get { self[FileClient.self] } + set { self[FileClient.self] = newValue } + } +} + +/// Represents interactions with the file system. +/// +/// +@DependencyClient +public struct FileClient: Sendable { + + /// Copy an item from one location to another. + public var copy: @Sendable (URL, URL) async throws -> Void + + /// Create a directory at the given location. + public var createDirectory: @Sendable (URL) async throws -> Void + + /// Delete the item at the given location. + public var delete: @Sendable (URL) async throws -> Void + + /// Check if a file exists at the given location. + public var fileExists: @Sendable (URL) -> Bool = { _ in true } + + /// Find an ansible-vault file in the given location, checking up to 1 level deep + /// in subfolders. + public var findVaultFile: @Sendable (URL) async throws -> URL? + + /// Return the user's home directory. + public var homeDirectory: @Sendable () -> URL = { URL(filePath: "~/") } + + /// Check if an item is a directory or not. + public var isDirectory: @Sendable (URL) async throws -> Bool + + /// Load a file from the given location. + public var load: @Sendable (URL) async throws -> Data + + /// Write data to a file at the given location. + public var write: @Sendable (Data, URL) async throws -> Void + + /// Find an ansible-vault file in the current directory, checking up to 1 level + /// deep in subfolders. + public func findVaultFileInCurrentDirectory() async throws -> URL? { + try await findVaultFile(URL(filePath: "./")) + } +} + +extension FileClient: DependencyKey { + public static let testValue: FileClient = Self() + + public static var liveValue: Self { + let manager = LiveFileClient() + return .init( + copy: { try await manager.copy($0, to: $1) }, + createDirectory: { try await manager.creatDirectory($0) }, + delete: { try await manager.delete($0) }, + fileExists: { manager.fileExists(at: $0) }, + findVaultFile: { try await manager.findVaultFile(in: $0) }, + homeDirectory: { manager.homeDirectory() }, + isDirectory: { manager.isDirectory($0) }, + load: { try await manager.load(from: $0) }, + write: { try await manager.write($0, to: $1) } + ) + } +} + +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 creatDirectory(_ url: URL) async throws { + try manager.createDirectory(at: url, withIntermediateDirectories: true) + } + + func delete(_ url: URL) async throws { + try manager.removeItem(at: url) + } + + func fileExists(at url: URL) -> Bool { + manager.fileExists(atPath: url.cleanFilePath) + } + + func findVaultFile(in url: URL) async throws -> URL? { + guard isDirectory(url) else { return nil } + let urls = try manager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) + + guard let vault = urls.firstVaultFile else { + // check subfolders, 1 layer deep. + let subfolders = urls.filter { isDirectory($0) } + for folder in subfolders { + let vault = try manager.contentsOfDirectory( + at: folder, + includingPropertiesForKeys: nil + ) + .firstVaultFile + + if let vault { return vault } + } + // Didn't find a file. + return nil + } + + return vault + } + + func homeDirectory() -> URL { + manager.homeDirectoryForCurrentUser + } + + func isDirectory(_ url: URL) -> Bool { + var isDirectory: ObjCBool = false + manager.fileExists(atPath: url.cleanFilePath, isDirectory: &isDirectory) + return isDirectory.boolValue + } + + 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) + } +} + +private extension Array where Element == URL { + var firstVaultFile: URL? { + first { url in + let string = url.absoluteString + return string.hasSuffix("vault.yml") || string.hasSuffix("vault.yaml") + } + } +} + +public extension URL { + var cleanFilePath: String { + absoluteString + .replacing("file://", with: "") + .replacing("/private", with: "") + } +} diff --git a/Sources/PandocClient/Constants.swift b/Sources/PandocClient/Constants.swift new file mode 100644 index 0000000..4981493 --- /dev/null +++ b/Sources/PandocClient/Constants.swift @@ -0,0 +1,9 @@ +extension PandocClient { + + /// Represents constant string values needed internally. + enum Constants { + static let pandocCommand = "pandoc" + static let defaultOutputFileName = "Report" + static let defaultPdfEngine = "xelatex" + } +} diff --git a/Sources/PandocClient/PandocClient+Run.swift b/Sources/PandocClient/PandocClient+Run.swift new file mode 100644 index 0000000..8836342 --- /dev/null +++ b/Sources/PandocClient/PandocClient+Run.swift @@ -0,0 +1,245 @@ +import CommandClient +import ConfigurationClient +import Dependencies +import Foundation +import PlaybookClient + +extension PandocClient.RunOptions { + + func run( + _ fileType: PandocClient.FileType, + _ environment: [String: String] + ) async throws -> String { + @Dependency(\.commandClient) var commandClient + @Dependency(\.logger) var logger + @Dependency(\.playbookClient) var playbookClient + + return try await commandClient.run(logging: loggingOptions, quiet: quiet, shell: shell) { + let ensuredOptions = try await self.ensuredOptions(fileType) + + let projectDirectory = self.projectDirectory ?? environment["PWD"] + + guard let projectDirectory else { + throw ProjectDirectoryNotSpecified() + } + + if shouldBuildProject { + logger.debug("Building project...") + try await playbookClient.run.buildProject(.init( + projectDirectory: projectDirectory, + shared: .init( + extraOptions: nil, + inventoryFilePath: nil, + loggingOptions: loggingOptions, + quiet: quiet, + shell: shell + ) + )) + } + + let outputDirectory = self.outputDirectory ?? projectDirectory + let outputPath = "\(outputDirectory)/\(ensuredOptions.ensuredExtensionFileName)" + + let arguments = ensuredOptions.makeArguments( + outputPath: outputPath, + projectDirectory: projectDirectory + ) + + logger.debug("Pandoc arguments: \(arguments)") + + return (arguments, outputPath) + } + } + + func ensuredOptions( + _ fileType: PandocClient.FileType + ) async throws -> EnsuredPandocOptions { + @Dependency(\.configurationClient) var configurationClient + @Dependency(\.logger) var logger + + let configuration = try await configurationClient.findAndLoad() + logger.debug("Configuration: \(configuration)") + + return try await ensurePandocOptions( + configuration: configuration, + fileType: fileType, + options: self + ) + } +} + +extension PandocClient.FileType { + var fileExtension: String { + switch self { + case .html: return "html" + case .latex: return "tex" + case .pdf: return "pdf" + } + } +} + +@_spi(Internal) +public struct EnsuredPandocOptions: Equatable, Sendable { + public let buildDirectory: String + public let extraOptions: [String]? + public let files: [String] + public let includeInHeader: [String] + public let outputFileName: String + public let outputFileType: PandocClient.FileType + public let pdfEngine: String? + + public var ensuredExtensionFileName: String { + let extensionString = ".\(outputFileType.fileExtension)" + + if !outputFileName.hasSuffix(extensionString) { + return outputFileName + extensionString + } + return outputFileName + } + + func makeArguments( + outputPath: String, + projectDirectory: String + ) -> [String] { + var arguments = [PandocClient.Constants.pandocCommand] + + arguments += includeInHeader.map { + "--include-in-header=\(projectDirectory)/\(buildDirectory)/\($0)" + } + + if let pdfEngine { + arguments.append("--pdf-engine=\(pdfEngine)") + } + + arguments.append("--output=\(outputPath)") + + if let extraOptions { + arguments.append(contentsOf: extraOptions) + } + + arguments += files.map { + "\(projectDirectory)/\(buildDirectory)/\($0)" + } + + return arguments + } +} + +@_spi(Internal) +public func ensurePandocOptions( + configuration: Configuration, + fileType: PandocClient.FileType, + options: PandocClient.RunOptions +) async throws -> EnsuredPandocOptions { + let defaults = Configuration.Generate.default + + return .init( + buildDirectory: options.parseBuildDirectory(configuration.generate, defaults), + extraOptions: options.extraOptions, + files: options.parseFiles(configuration.generate, defaults), + includeInHeader: options.parseIncludeInHeader(configuration.generate, defaults), + outputFileName: options.parseOutputFileName(configuration.generate, defaults), + outputFileType: fileType, + pdfEngine: fileType.parsePdfEngine(configuration.generate, defaults) + ) +} + +@_spi(Internal) +public extension PandocClient.FileType { + func parsePdfEngine( + _ configuration: Configuration.Generate?, + _ defaults: Configuration.Generate + ) -> String? { + switch self { + case .html, .latex: + return nil + case let .pdf(engine: engine): + if let engine { + return engine + } else if let engine = configuration?.pdfEngine { + return engine + } else if let engine = defaults.pdfEngine { + return engine + } else { + return PandocClient.Constants.defaultPdfEngine + } + } + } +} + +@_spi(Internal) +public extension PandocClient.RunOptions { + func parseFiles( + _ configuration: Configuration.Generate?, + _ defaults: Configuration.Generate + ) -> [String] { + @Dependency(\.logger) var logger + + if let files = files { + return files + } else if let files = configuration?.files { + return files + } else if let files = defaults.files { + return files + } else { + logger.warning("Files not specified, this could lead to errors.") + return [] + } + } + + func parseIncludeInHeader( + _ configuration: Configuration.Generate?, + _ defaults: Configuration.Generate + ) -> [String] { + @Dependency(\.logger) var logger + + if let files = includeInHeader { + return files + } else if let files = configuration?.includeInHeader { + return files + } else if let files = defaults.includeInHeader { + return files + } else { + logger.warning("Include in header files not specified, this could lead to errors.") + return [] + } + } + + func parseOutputFileName( + _ configuration: Configuration.Generate?, + _ defaults: Configuration.Generate + ) -> String { + @Dependency(\.logger) var logger + + if let output = outputFileName { + return output + } else if let output = configuration?.outputFileName { + return output + } else if let output = defaults.outputFileName { + return output + } else { + logger.warning("Output file name not specified, this could lead to errors.") + return PandocClient.Constants.defaultOutputFileName + } + } + + func parseBuildDirectory( + _ configuration: Configuration.Generate?, + _ defaults: Configuration.Generate + ) -> String { + @Dependency(\.logger) var logger + + if let output = buildDirectory { + return output + } else if let output = configuration?.buildDirectory { + return output + } else if let output = defaults.buildDirectory { + return output + } else { + logger.warning("Output file name not specified, this could lead to errors.") + return ".build" + } + } +} + +struct ProjectDirectoryNotSpecified: Error {} diff --git a/Sources/PandocClient/PandocClient.swift b/Sources/PandocClient/PandocClient.swift new file mode 100644 index 0000000..c79089d --- /dev/null +++ b/Sources/PandocClient/PandocClient.swift @@ -0,0 +1,141 @@ +import CommandClient +import ConfigurationClient +import Dependencies +import DependenciesMacros +import Foundation + +public extension DependencyValues { + + /// Represents interactions with the `pandoc` command line application. + /// + /// The `pandoc` command line application is used to generate the final output + /// documents from a home performance assessment project. + /// + var pandocClient: PandocClient { + get { self[PandocClient.self] } + set { self[PandocClient.self] = newValue } + } +} + +@DependencyClient +public struct PandocClient: Sendable { + + /// Run a pandoc command. + public var run: Run + + /// Represents the pandoc commands that we can run. + @DependencyClient + public struct Run: Sendable { + + /// Generate a latex file from the given options, returning the path the + /// generated file was written to. + public var generateLatex: @Sendable (RunOptions) async throws -> String + + /// Generate an html file from the given options, returning the path the + /// generated file was written to. + public var generateHtml: @Sendable (RunOptions) async throws -> String + + /// Generate a pdf file from the given options, returning the path the + /// generated file was written to. + public var generatePdf: @Sendable (RunOptions, String?) async throws -> String + + /// Generate a pdf file from the given options, returning the path the + /// generated file was written to. + /// + /// - Parameters: + /// - options: The shared run options. + /// - pdfEngine: The pdf-engine to use to generate the pdf file. + public func generatePdf( + _ options: RunOptions, + pdfEngine: String? = nil + ) async throws -> String { + try await generatePdf(options, pdfEngine) + } + + } + + /// Represents the shared options used to run a `pandoc` command. + /// + /// + public struct RunOptions: Equatable, Sendable { + + let buildDirectory: String? + let extraOptions: [String]? + let files: [String]? + let loggingOptions: LoggingOptions + let includeInHeader: [String]? + let outputDirectory: String? + let outputFileName: String? + let projectDirectory: String? + let quiet: Bool + let shell: String? + let shouldBuildProject: Bool + + /// Create the shared run options. + /// + /// - Parameters: + /// - buildDirectory: Specify the build directory of the project. + /// - extraOptions: Extra arguments / options passed to the `pandoc` command. + /// - file: Files used to build the final output. + /// - loggingOptions: The logging options used when running the command. + /// - includeInHeader: Files to include in the header of the final output document. + /// - outputDirectory: Optional output directory for the output file, defaults to current working directory. + /// - projectDirectory: Optional project directory, defaults to current working directory. + /// - outputFileName: Override the output file name. + /// - quiet: Don't log output from the command. + /// - shell: Optional shell to use when calling the pandoc command. + /// - shouldBuildProject: Build the project prior to generating the output file. + public init( + buildDirectory: String? = nil, + extraOptions: [String]? = nil, + files: [String]? = nil, + loggingOptions: LoggingOptions, + includeInHeader: [String]? = nil, + outputDirectory: String? = nil, + projectDirectory: String? = nil, + outputFileName: String? = nil, + quiet: Bool = false, + shell: String? = nil, + shouldBuild shouldBuildProject: Bool = true + ) { + self.buildDirectory = buildDirectory + self.extraOptions = extraOptions + self.files = files + self.loggingOptions = loggingOptions + self.includeInHeader = includeInHeader + self.outputDirectory = outputDirectory + self.outputFileName = outputFileName + self.projectDirectory = projectDirectory + self.quiet = quiet + self.shell = shell + self.shouldBuildProject = shouldBuildProject + } + + } + + /// Represents the file types that we can generate output files for. + @_spi(Internal) + public enum FileType: Equatable, Sendable { + case html + case latex + case pdf(engine: String?) + } +} + +extension PandocClient: DependencyKey { + public static let testValue: PandocClient = Self(run: Run()) + + public static func live(environment: [String: String]) -> PandocClient { + .init( + run: Run( + generateLatex: { try await $0.run(.latex, environment) }, + generateHtml: { try await $0.run(.html, environment) }, + generatePdf: { try await $0.run(.pdf(engine: $1), environment) } + ) + ) + } + + public static var liveValue: PandocClient { + .live(environment: ProcessInfo.processInfo.environment) + } +} diff --git a/Sources/PlaybookClient/Constants.swift b/Sources/PlaybookClient/Constants.swift new file mode 100644 index 0000000..9e4b74c --- /dev/null +++ b/Sources/PlaybookClient/Constants.swift @@ -0,0 +1,14 @@ +// TODO: Use an actual version tag for playbook repo. +// TODO: Use an externally public url for the playbook repo. + +public extension PlaybookClient { + @_spi(Internal) + enum Constants { + public static let defaultInstallationPath = "~/.local/share/hpa/playbook" + public static let inventoryFileName = "inventory.ini" + public static let playbookCommand = "ansible-playbook" + public static let playbookFileName = "main.yml" + public static let playbookRepoUrl = "https://git.housh.dev/michael/ansible-hpa-playbook.git" + public static let playbookRepoVersion = "main" + } +} diff --git a/Sources/PlaybookClient/PlaybookClient+Repository.swift b/Sources/PlaybookClient/PlaybookClient+Repository.swift new file mode 100644 index 0000000..005de58 --- /dev/null +++ b/Sources/PlaybookClient/PlaybookClient+Repository.swift @@ -0,0 +1,79 @@ +import CommandClient +import ConfigurationClient +import Dependencies +import FileClient +import Foundation +import ShellClient + +extension PlaybookClient.Repository { + + static func findDirectory( + configuration: Configuration? = nil + ) async throws -> String { + @Dependency(\.configurationClient) var configurationClient + + var configuration: Configuration! = configuration + if configuration == nil { + configuration = try await configurationClient.findAndLoad() + } + + return configuration.playbook?.directory + ?? PlaybookClient.Constants.defaultInstallationPath + } + + static func installPlaybook( + configuration: Configuration? + ) async throws { + @Dependency(\.commandClient) var commandClient + @Dependency(\.configurationClient) var configurationClient + @Dependency(\.fileClient) var fileClient + @Dependency(\.logger) var logger + + var configuration: Configuration! = configuration + + if configuration == nil { + configuration = try await configurationClient.findAndLoad() + } + + let (path, version) = parsePlaybookPathAndVerion( + configuration?.playbook + ) + + let parentDirectory = URL(filePath: path) + .deletingLastPathComponent() + + let parentExists = try await fileClient.isDirectory(parentDirectory) + if !parentExists { + try await fileClient.createDirectory(parentDirectory) + } + + let playbookExists = try await fileClient.isDirectory(URL(filePath: path)) + + if !playbookExists { + logger.debug("Cloning playbook to: \(path)") + try await commandClient.run( + "git", "clone", "--branch", version, + PlaybookClient.Constants.playbookRepoUrl, path + ) + } else { + logger.debug("Playbook exists, ensuring it's up to date.") + try await commandClient.run( + in: path, + "git", "pull", "--tags" + ) + try await commandClient.run( + in: path, + "git", "checkout", version + ) + } + } + + static func parsePlaybookPathAndVerion( + _ configuration: Configuration.Playbook? + ) -> (path: String, version: String) { + return ( + path: configuration?.directory ?? PlaybookClient.Constants.defaultInstallationPath, + version: configuration?.version ?? PlaybookClient.Constants.playbookRepoVersion + ) + } +} diff --git a/Sources/PlaybookClient/PlaybookClient+RunPlaybook.swift b/Sources/PlaybookClient/PlaybookClient+RunPlaybook.swift new file mode 100644 index 0000000..6bdd7d9 --- /dev/null +++ b/Sources/PlaybookClient/PlaybookClient+RunPlaybook.swift @@ -0,0 +1,245 @@ +import CommandClient +import ConfigurationClient +import Dependencies +import Foundation + +extension PlaybookClient.RunPlaybook.BuildOptions { + + func run() async throws { + try await shared.run { arguments, _ in + let projectDirectory = projectDirectory + ?? ProcessInfo.processInfo.environment["PWD"] + + guard let projectDirectory else { + throw PlaybookClientError.projectDirectoryNotFound + } + + arguments.append(contentsOf: [ + "--tags", "build-project", + "--extra-vars", "project_dir=\(projectDirectory)" + ]) + + if let extraOptions = shared.extraOptions { + arguments.append(contentsOf: extraOptions) + } + } + } +} + +extension PlaybookClient.RunPlaybook.CreateOptions { + + func run(encoder jsonEncoder: JSONEncoder?) async throws { + try await shared.run { arguments, configuration in + let jsonData = try createJSONData( + configuration: configuration, + encoder: jsonEncoder + ) + + guard let json = String(data: jsonData, encoding: .utf8) else { + throw PlaybookClientError.encodingError + } + + arguments.append(contentsOf: [ + "--tags", "setup-project", + "--extra-vars", "project_dir=\(projectDirectory)", + "--extra-vars", "'\(json)'" + ]) + + if let extraOptions = shared.extraOptions { + arguments.append(contentsOf: extraOptions) + } + } + } +} + +extension PlaybookClient.RunPlaybook.GenerateTemplateOptions { + func run() async throws -> String { + try await shared.run { arguments, _ in + arguments.append(contentsOf: [ + "--tags", "repo-template", + "--extra-vars", "output_dir=\(templateDirectory)" + ]) + + if let templateVarsDirectory { + arguments.append(contentsOf: ["--extra-vars", "repo_vars_dir=\(templateVarsDirectory)"]) + } + + if !useVault { + arguments.append(contentsOf: ["--extra-vars", "use_vault=false"]) + } + + return templateDirectory + } + } +} + +extension PlaybookClient.RunPlaybook.SharedRunOptions { + + @discardableResult + func run( + _ apply: @Sendable @escaping (inout [String], Configuration) throws -> T + ) async throws -> T { + @Dependency(\.commandClient) var commandClient + + return try await commandClient.run( + logging: loggingOptions, + quiet: quiet, + shell: shell + ) { + @Dependency(\.logger) var logger + @Dependency(\.configurationClient) var configurationClient + + let configuration = try await configurationClient.findAndLoad() + + var arguments = try await PlaybookClient.RunPlaybook.makeCommonArguments( + configuration: configuration, + inventoryFilePath: inventoryFilePath + ) + + let output = try apply(&arguments, configuration) + + return (arguments, output) + } + } +} + +@_spi(Internal) +public extension PlaybookClient.RunPlaybook { + + static func ensuredInventoryPath( + _ optionalInventoryPath: String?, + configuration: Configuration, + playbookDirectory: String + ) -> String { + guard let path = optionalInventoryPath else { + guard let path = configuration.playbook?.inventory else { + return "\(playbookDirectory)/\(PlaybookClient.Constants.inventoryFileName)" + } + return path + } + return path + } + + static func makeCommonArguments( + configuration: Configuration, + inventoryFilePath: String? + ) async throws -> [String] { + @Dependency(\.logger) var logger + + let playbookDirectory = try await PlaybookClient.Repository.findDirectory(configuration: configuration) + let playbookPath = "\(playbookDirectory)/\(PlaybookClient.Constants.playbookFileName)" + logger.trace("Playbook path: \(playbookPath)") + + let inventoryPath = ensuredInventoryPath( + inventoryFilePath, + configuration: configuration, + playbookDirectory: playbookDirectory + ) + logger.trace("Inventory path: \(inventoryPath)") + + var arguments = [ + PlaybookClient.Constants.playbookCommand, playbookPath, + "--inventory", inventoryPath + ] + + if let defaultArgs = configuration.args { + arguments.append(contentsOf: defaultArgs) + } + + if configuration.useVaultArgs, let vaultArgs = configuration.vault.args { + arguments.append(contentsOf: vaultArgs) + } + + logger.trace("Common arguments: \(inventoryPath)") + return arguments + } + +} + +// +// NOTE: We're not using the `Coders` client because we generally do not +// want the output to be `prettyPrinted` or anything, unless we're running +// tests, so we use a supplied json encoder. +// +@_spi(Internal) +public extension PlaybookClient.RunPlaybook.CreateOptions { + + func createJSONData( + configuration: Configuration, + encoder: JSONEncoder? + ) throws -> Data { + @Dependency(\.logger) var logger + + let encoder = encoder ?? jsonEncoder + + let templateDir = template?.directory ?? configuration.template.directory + let templateRepo = template?.url ?? configuration.template.url + let version = template?.version ?? configuration.template.version + + logger.debug(""" + (\(useLocalTemplateDirectory), \(String(describing: templateDir)), \(String(describing: templateRepo))) + """) + + switch (useLocalTemplateDirectory, templateDir, templateRepo) { + case (true, .none, _): + // User supplied they wanted to use a local template directory, but we could not find + // the path set from command line or in configuration. + throw PlaybookClientError.templateDirectoryNotFound + case let (false, _, .some(repo)): + // User did not supply they wanted to use a local template directory, and we found a repo url that was + // either set by the command line or found in the configuration. + logger.debug("Using repo.") + return try encoder.encode(TemplateRepo(repo: repo, version: version)) + case let (true, .some(templateDir), _): + // User supplied they wanted to use a local template directory, and we found the template directory + // either set by the command line or in the configuration. + logger.debug("Using template directory.") + return try encoder.encode(TemplateDirJson(path: templateDir)) + case let (false, .some(templateDir), _): + // User supplied they did not wanted to use a local template directory, and we found the template directory + // either set by the command line or in the configuration, and no repo was found / handled previously. + logger.debug("Using template directory.") + return try encoder.encode(TemplateDirJson(path: templateDir)) + case (_, .none, .none): + // We could not find a repo or template directory. + throw PlaybookClientError.templateDirectoryOrRepoNotSpecified + } + } +} + +private struct TemplateDirJson: Encodable { + + let template: Template + + init(path: String) { + self.template = .init(path: path) + } + + struct Template: Encodable { + let path: String + } +} + +private struct TemplateRepo: Encodable { + + let template: Template + + init(repo: String, version: String?) { + self.template = .init(repo: .init(url: repo, version: version ?? "main")) + } + + struct Template: Encodable { + let repo: Repo + } + + struct Repo: Encodable { + let url: String + let version: String + } +} + +private let jsonEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] + return encoder +}() diff --git a/Sources/PlaybookClient/PlaybookClient.swift b/Sources/PlaybookClient/PlaybookClient.swift new file mode 100644 index 0000000..b4dd788 --- /dev/null +++ b/Sources/PlaybookClient/PlaybookClient.swift @@ -0,0 +1,262 @@ +import CommandClient +import ConfigurationClient +import Dependencies +import DependenciesMacros +import FileClient +import Foundation +import ShellClient + +// TODO: Add update checks and pull for the playbook. + +public extension DependencyValues { + + /// Manages interactions with the playbook repository as well as `ansible-playbook` + /// command line application. + /// + /// + var playbookClient: PlaybookClient { + get { self[PlaybookClient.self] } + set { self[PlaybookClient.self] = newValue } + } +} + +@DependencyClient +public struct PlaybookClient: Sendable { + + /// Manages interactions with the playbook repository. + public var repository: Repository + + /// Run the `ansible-playbook` command line application. + public var run: RunPlaybook +} + +public extension PlaybookClient { + + /// Manages interactions with the `ansible-hpa-playbook` repository, which is + /// used to build and generate home performance assessment projects. + @DependencyClient + struct Repository: Sendable { + + /// Install the repository based on the given configuration. If configuration is + /// not supplied then the default location for the playbook repository is + /// `~/.local/share/hpa/playbook` + public var install: @Sendable (Configuration?) async throws -> Void + + /// Get the current directory of the playbook repository based on the given + /// configuration. + public var directory: @Sendable (Configuration?) async throws -> String + + /// Install the playbook in the default location. + public func install() async throws { + try await install(nil) + } + + /// Get the current directory path of the playbook repository. + public func directory() async throws -> String { + try await directory(nil) + } + } +} + +public extension PlaybookClient { + + /// Runs the `ansible-playbook` command line application with the `ansible-hpa-playbook`. + /// + /// This is used to build and create home performance projects. It can also generate a + /// template for a user to customize to their use case. + @DependencyClient + struct RunPlaybook: Sendable { + + /// Build a home performance assesment project with the given options. + public var buildProject: @Sendable (BuildOptions) async throws -> Void + + /// Create a new home performance assesment project with the given options. + public var createProject: @Sendable (CreateOptions, JSONEncoder?) async throws -> Void + + /// Generate a user's template from the default home performance template. + public var generateTemplate: @Sendable (GenerateTemplateOptions) async throws -> String + + /// Create a new home performance assesment project with the given options. + /// + /// - Parameters: + /// - options: The options used to create the project. + public func createProject(_ options: CreateOptions) async throws { + try await createProject(options, nil) + } + + /// Represents options that are shared for all the `ansible-playbook` commands. + public struct SharedRunOptions: Equatable, Sendable { + + /// Extra arguments / options passed to the `ansible-playbook` command. + let extraOptions: [String]? + + /// Specify the inventory file path. + let inventoryFilePath: String? + + /// The logging options used when running the command. + let loggingOptions: LoggingOptions + + /// Disables log output of the command. + let quiet: Bool + + /// Optional shell to use when running the command. + let shell: String? + + /// Create the shared options. + /// + /// - Parameters: + /// - extraOptions: The extra arguments / options to pass to the command. + /// - inventoryFilePath: Specify the inventory file path. + /// - loggingOptions: The logging options used when running the command. + /// - quiet: Disable log output from the command. + /// - shell: Specify a shell used when running the command. + public init( + extraOptions: [String]? = nil, + inventoryFilePath: String? = nil, + loggingOptions: LoggingOptions, + quiet: Bool = false, + shell: String? = nil + ) { + self.extraOptions = extraOptions + self.inventoryFilePath = inventoryFilePath + self.loggingOptions = loggingOptions + self.quiet = quiet + self.shell = shell + } + } + + /// Options require when calling the build project command. + @dynamicMemberLookup + public struct BuildOptions: Equatable, Sendable { + + /// An optional project directory, if not supplied then we will use the current working directory. + let projectDirectory: String? + + /// The shared run options. + let shared: SharedRunOptions + + /// Create new build options. + /// + /// - Parameters: + /// - projectDirectory: The optional project directory to build, if not supplied then we'll use the current working directory. + /// - shared: The shared run options. + public init( + projectDirectory: String? = nil, + shared: SharedRunOptions + ) { + self.projectDirectory = projectDirectory + self.shared = shared + } + + public subscript(dynamicMember keyPath: KeyPath) -> T { + shared[keyPath: keyPath] + } + } + + /// Options required when creating a new home performance assessment project. + @dynamicMemberLookup + public struct CreateOptions: Equatable, Sendable { + /// The directory to generate the new project in. + let projectDirectory: String + /// Shared run options. + let shared: SharedRunOptions + /// Custom template configuration to use. + let template: Configuration.Template? + /// Specify whether we should only use a local template directory to create the project from. + let useLocalTemplateDirectory: Bool + + /// Create new create options. + /// + /// - Parameters: + /// - projectDirectory: The directory to generate the project in. + /// - shared: The shared run options. + /// - template: Custom template configuration used when generating the project. + /// - useLocalTemplateDirectory: Whether to use a local template directory, not a template repository. + public init( + projectDirectory: String, + shared: SharedRunOptions, + template: Configuration.Template? = nil, + useLocalTemplateDirectory: Bool + ) { + self.projectDirectory = projectDirectory + self.shared = shared + self.template = template + self.useLocalTemplateDirectory = useLocalTemplateDirectory + } + + public subscript(dynamicMember keyPath: KeyPath) -> T { + shared[keyPath: keyPath] + } + } + + /// Options required when generating a new template repository / directory. + @dynamicMemberLookup + public struct GenerateTemplateOptions: Equatable, Sendable { + /// The shared run options. + let shared: SharedRunOptions + /// The path to generate the template in. + let templateDirectory: String + /// Specify the name of the directory for template variables. + let templateVarsDirectory: String? + /// Specify whether to use `ansible-vault` encrypted variables. + let useVault: Bool + + /// Create new generate template options. + /// + /// - Parameters: + /// - shared: The shared run options + /// - templateDirectory: The path to generate the template in. + /// - templateVarsDirectory: Specify the name of the directory for template variables. + /// - useVault: Specify wheter to use `ansible-vault` encrypted variables. + public init( + shared: SharedRunOptions, + templateDirectory: String, + templateVarsDirectory: String? = nil, + useVault: Bool = true + ) { + self.shared = shared + self.templateDirectory = templateDirectory + self.templateVarsDirectory = templateVarsDirectory + self.useVault = useVault + } + + public subscript(dynamicMember keyPath: KeyPath) -> T { + shared[keyPath: keyPath] + } + } + } +} + +extension PlaybookClient.Repository: DependencyKey { + public static var liveValue: Self { + .init { + try await installPlaybook(configuration: $0) + } directory: { + try await findDirectory(configuration: $0) + } + } +} + +extension PlaybookClient.RunPlaybook: DependencyKey { + public static var liveValue: PlaybookClient.RunPlaybook { + .init( + buildProject: { try await $0.run() }, + createProject: { try await $0.run(encoder: $1) }, + generateTemplate: { try await $0.run() } + ) + } +} + +extension PlaybookClient: DependencyKey { + public static let testValue: PlaybookClient = Self( + repository: Repository(), + run: RunPlaybook() + ) + + public static var liveValue: PlaybookClient { + .init( + repository: .liveValue, + run: .liveValue + ) + } +} diff --git a/Sources/PlaybookClient/PlaybookClientError.swift b/Sources/PlaybookClient/PlaybookClientError.swift new file mode 100644 index 0000000..039696b --- /dev/null +++ b/Sources/PlaybookClient/PlaybookClientError.swift @@ -0,0 +1,8 @@ +import Foundation + +enum PlaybookClientError: Error { + case encodingError + case projectDirectoryNotFound + case templateDirectoryNotFound + case templateDirectoryOrRepoNotSpecified +} diff --git a/Sources/TestSupport/TestSupport.swift b/Sources/TestSupport/TestSupport.swift new file mode 100644 index 0000000..dbbbf46 --- /dev/null +++ b/Sources/TestSupport/TestSupport.swift @@ -0,0 +1,167 @@ +@_spi(Internal) @_exported import CommandClient +@_exported import Dependencies +import Foundation +import Logging +@_exported import ShellClient + +public protocol TestCase {} + +public extension TestCase { + + static var loggingOptions: LoggingOptions { + let levelString = ProcessInfo.processInfo.environment["LOG_LEVEL"] ?? "debug" + let logLevel = Logger.Level(rawValue: levelString) ?? .debug + return .init(commandName: "\(Self.self)", logLevel: logLevel) + } + + func withCapturingCommandClient( + _ key: String, + dependencies setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in }, + run: @Sendable @escaping () async throws -> Void, + assert: @Sendable @escaping (CommandClient.RunCommandOptions) -> Void + ) async throws { + let captured = CommandClient.CapturingClient() + try await withDependencies { + $0.commandClient = .capturing(captured) + setupDependencies(&$0) + } operation: { + try await Self.loggingOptions.withLogger { + try await run() + + guard let options = await captured.options else { + throw TestSupportError.optionsNotSet + } + + assert(options) + } + } + } + + 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() + } +} + +enum TestSupportError: Error { + case optionsNotSet +} diff --git a/Sources/TestSupport/WithTempDir.swift b/Sources/TestSupport/WithTempDir.swift new file mode 100644 index 0000000..4d8e619 --- /dev/null +++ b/Sources/TestSupport/WithTempDir.swift @@ -0,0 +1,15 @@ +import Foundation + +// swiftlint:disable force_try +public func withTemporaryDirectory( + _ operation: @Sendable (URL) async throws -> Void +) async rethrows { + let temporaryDirectory = FileManager.default + .temporaryDirectory + .appending(path: UUID().uuidString) + try! FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: false) + try await operation(temporaryDirectory) + try! FileManager.default.removeItem(at: temporaryDirectory) +} + +// swiftlint:enable force_try diff --git a/Sources/VaultClient/VaultClient.swift b/Sources/VaultClient/VaultClient.swift new file mode 100644 index 0000000..fcca634 --- /dev/null +++ b/Sources/VaultClient/VaultClient.swift @@ -0,0 +1,177 @@ +import CommandClient +import ConfigurationClient +import Dependencies +import DependenciesMacros +import FileClient + +public extension DependencyValues { + /// Manages interactions with `ansible-vault` command line application. + var vaultClient: VaultClient { + get { self[VaultClient.self] } + set { self[VaultClient.self] = newValue } + } +} + +@DependencyClient +public struct VaultClient: Sendable { + /// Run an `ansible-vault` command. + public var run: Run + + @DependencyClient + public struct Run: Sendable { + /// Decrypt an `ansible-vault` file. + public var decrypt: @Sendable (RunOptions) async throws -> String + /// Encrypt an `ansible-vault` file. + public var encrypt: @Sendable (RunOptions) async throws -> String + } + + public struct RunOptions: Equatable, Sendable { + + /// Extra arguments / options passed to the `ansible-vault` command. + let extraOptions: [String]? + /// Logging options to use while running the command. + let loggingOptions: LoggingOptions + /// The optional output file path. + let outputFilePath: String? + /// Disable logging output. + let quiet: Bool + /// Optional shell used to call the command. + let shell: String? + /// The path to the vault file, if not supplied we will search the current directory for it. + let vaultFilePath: String? + + /// Create new run options. + /// + /// - Parameters: + /// - extraOptions: Extra arguments / options passed to the command. + /// - loggingOptions: The logging options used while running the command. + /// - outputFilePath: The optional output file path. + /// - quiet: Disable logging output of the command. + /// - shell: The shell used to call the command. + /// - vaultFilePath: The vault file, if not supplied we will search in the current working directory. + public init( + extraOptions: [String]? = nil, + loggingOptions: LoggingOptions, + outputFilePath: String? = nil, + quiet: Bool = false, + shell: String? = nil, + vaultFilePath: String? = nil + ) { + self.extraOptions = extraOptions + self.loggingOptions = loggingOptions + self.outputFilePath = outputFilePath + self.quiet = quiet + self.shell = shell + self.vaultFilePath = vaultFilePath + } + } + + @_spi(Internal) + public enum Route: String, Equatable, Sendable { + case decrypt + case encrypt + + public var verb: String { rawValue } + } +} + +extension VaultClient: DependencyKey { + + public static let testValue: VaultClient = Self(run: Run()) + + public static var liveValue: VaultClient { + .init( + run: .init( + decrypt: { try await $0.run(route: .decrypt) }, + encrypt: { try await $0.run(route: .encrypt) } + ) + ) + } +} + +@_spi(Internal) +public extension VaultClient { + enum Constants { + public static let vaultCommand = "ansible-vault" + } +} + +extension VaultClient.RunOptions { + + // Sets up the default arguments and runs the `ansible-vault` command, + // returning the output file path, which is either supplied by the caller + // or found via the `fileClient.findVaultFileInCurrentDirectory`. + // + // This allows the output to be piped into other commands. + // + // + @discardableResult + func run(route: VaultClient.Route) async throws -> String { + @Dependency(\.commandClient) var commandClient + + return try await commandClient.run( + logging: loggingOptions, + quiet: quiet, + shell: shell + ) { + @Dependency(\.configurationClient) var configurationClient + @Dependency(\.fileClient) var fileClient + @Dependency(\.logger) var logger + + var output: String? + + let configuration = try await configurationClient.findAndLoad() + logger.trace("Configuration: \(configuration)") + + var vaultFilePath: String? = vaultFilePath + + if vaultFilePath == nil { + vaultFilePath = try await fileClient + .findVaultFileInCurrentDirectory()? + .cleanFilePath + } + + guard let vaultFilePath else { + throw VaultClientError.vaultFileNotFound + } + output = vaultFilePath + + logger.trace("Vault file: \(vaultFilePath)") + + var arguments = [ + VaultClient.Constants.vaultCommand, + route.verb + ] + + if let outputFilePath { + arguments.append(contentsOf: ["--output", outputFilePath]) + output = outputFilePath + } + + if let extraOptions { + arguments.append(contentsOf: extraOptions) + } + + if let vaultArgs = configuration.vault.args { + arguments.append(contentsOf: vaultArgs) + } + + if arguments.contains("encrypt"), + !arguments.contains("--encrypt-vault-id"), + let id = configuration.vault.encryptId + { + arguments.append(contentsOf: ["--encrypt-vault-id", id]) + } + + arguments.append(vaultFilePath) + + logger.trace("Arguments: \(arguments)") + + return (arguments, output ?? "") + } + } +} + +enum VaultClientError: Error { + case vaultFileNotFound +} diff --git a/Sources/hpa/Application.swift b/Sources/hpa/Application.swift index ba17e65..652b296 100644 --- a/Sources/hpa/Application.swift +++ b/Sources/hpa/Application.swift @@ -9,8 +9,13 @@ struct Application: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: Constants.appName, abstract: createAbstract("A utility for working with ansible hpa playbook."), + version: VERSION ?? "0.0.0", subcommands: [ - BuildCommand.self, CreateCommand.self, VaultCommand.self, UtilsCommand.self + BuildCommand.self, + CreateCommand.self, + GenerateCommand.self, + VaultCommand.self, + UtilsCommand.self ] ) diff --git a/Sources/hpa/BuildCommand.swift b/Sources/hpa/BuildCommand.swift index e278848..362d7c8 100644 --- a/Sources/hpa/BuildCommand.swift +++ b/Sources/hpa/BuildCommand.swift @@ -1,6 +1,7 @@ import ArgumentParser -import CliClient import Dependencies +import Foundation +import PlaybookClient struct BuildCommand: AsyncParsableCommand { @@ -9,29 +10,38 @@ struct BuildCommand: AsyncParsableCommand { static let configuration = CommandConfiguration.playbook( commandName: commandName, abstract: "Build a home performance assesment project.", - examples: (label: "Build Project", example: "\(commandName) /path/to/project") + examples: ( + label: "Build project when in the project directory.", + example: "\(commandName)" + ), + ( + label: "Build project from outside the project directory.", + example: "\(commandName) --project-directory /path/to/project" + ) ) @OptionGroup var globals: GlobalOptions - @Argument( + @Option( help: "Path to the project directory.", completion: .directory ) - var projectDir: String + var projectDirectory: String? @Argument( - help: "Extra arguments passed to the playbook." + help: "Extra arguments / options passed to the playbook." ) - var extraArgs: [String] = [] + var extraOptions: [String] = [] mutating func run() async throws { - try await runPlaybook( - commandName: Self.commandName, - globals: globals, - extraArgs: extraArgs, - "--tags", "build-project", - "--extra-vars", "project_dir=\(projectDir)" - ) + @Dependency(\.playbookClient) var playbookClient + + try await playbookClient.run.buildProject(.init( + projectDirectory: projectDirectory, + shared: globals.sharedPlaybookRunOptions( + commandName: Self.commandName, + extraOptions: extraOptions + ) + )) } } diff --git a/Sources/hpa/CreateCommand.swift b/Sources/hpa/CreateCommand.swift index ac9015a..d7931d8 100644 --- a/Sources/hpa/CreateCommand.swift +++ b/Sources/hpa/CreateCommand.swift @@ -1,14 +1,15 @@ import ArgumentParser -import CliClient +import ConfigurationClient import Dependencies import Foundation import Logging +import PlaybookClient struct CreateCommand: AsyncParsableCommand { static let commandName = "create" - static let configuration = CommandConfiguration.playbook2( + static let configuration = CommandConfiguration.playbook( commandName: commandName, abstract: "Create a home performance assesment project.", examples: ( @@ -53,118 +54,19 @@ struct CreateCommand: AsyncParsableCommand { @Argument( help: "Extra arguments passed to the playbook." ) - var extraArgs: [String] = [] + var extraOptions: [String] = [] mutating func run() async throws { - try await _run() - } - - private func _run() async throws { - try await withSetupLogger(commandName: Self.commandName, globals: globals) { - @Dependency(\.cliClient) var cliClient - @Dependency(\.logger) var logger - - let encoder = cliClient.encoder() - - let configuration = try cliClient.loadConfiguration() - - logger.debug("Configuration: \(configuration)") - - let jsonData = try parseOptions( - command: self, - configuration: configuration, - logger: logger, - encoder: encoder - ) - - guard let jsonString = String(data: jsonData, encoding: .utf8) else { - throw CreateError.encodingError - } - - logger.debug("JSON string: \(jsonString)") - - try await runPlaybook( + @Dependency(\.playbookClient) var playbookClient + try await playbookClient.run.createProject(.init( + projectDirectory: projectDir, + shared: globals.sharedPlaybookRunOptions( commandName: Self.commandName, - globals: self.globals, - configuration: configuration, - extraArgs: extraArgs, - "--tags", "setup-project", - "--extra-vars", "project_dir=\(self.projectDir)", - "--extra-vars", "'\(jsonString)'" - ) - } + extraOptions: extraOptions + ), + template: .init(directory: templateDir), + useLocalTemplateDirectory: localTemplateDir + )) + print(projectDir) } } - -private func parseOptions( - command: CreateCommand, - configuration: Configuration, - 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" - - logger.debug(""" - (\(command.localTemplateDir), \(String(describing: templateDir)), \(String(describing: templateRepo))) - """) - - switch (command.localTemplateDir, templateDir, templateRepo) { - case (true, .none, _): - // User supplied they wanted to use a local template directory, but we could not find - // the path set from command line or in configuration. - throw CreateError.templateDirNotFound - case let (false, _, .some(repo)): - // User did not supply they wanted to use a local template directory, and we found a repo url that was - // either set by the command line or found in the configuration. - logger.debug("Using repo.") - return try encoder.encode(TemplateRepo(repo: repo, version: version)) - case let (true, .some(templateDir), _): - // User supplied they wanted to use a local template directory, and we found the template directory - // either set by the command line or in the configuration. - logger.debug("Using template directory.") - return try encoder.encode(TemplateDirJson(path: templateDir)) - case let (false, .some(templateDir), _): - // User supplied they did not wanted to use a local template directory, and we found the template directory - // either set by the command line or in the configuration, and no repo was found / handled previously. - logger.debug("Using template directory.") - return try encoder.encode(TemplateDirJson(path: templateDir)) - case (_, .none, .none): - // We could not find a repo or template directory. - throw CreateError.templateDirOrRepoNotSpecified - } -} - -private struct TemplateDirJson: Encodable { - - let template: Template - - init(path: String) { - self.template = .init(path: path) - } - - struct Template: Encodable { - let path: String - } -} - -private struct TemplateRepo: Encodable { - - let template: Template - - init(repo: String, version: String?) { - self.template = .init(repo: repo, version: version ?? "main") - } - - struct Template: Encodable { - let repo: String - let version: String - } -} - -enum CreateError: Error { - case encodingError - case templateDirNotFound - case templateDirOrRepoNotSpecified -} diff --git a/Sources/hpa/GenerateCommands/GenerateCommand.swift b/Sources/hpa/GenerateCommands/GenerateCommand.swift new file mode 100644 index 0000000..0628bf1 --- /dev/null +++ b/Sources/hpa/GenerateCommands/GenerateCommand.swift @@ -0,0 +1,14 @@ +import ArgumentParser + +struct GenerateCommand: AsyncParsableCommand { + static let commandName = "generate" + + static let configuration = CommandConfiguration( + commandName: commandName, + subcommands: [ + GeneratePdfCommand.self, + GenerateLatexCommand.self, + GenerateHtmlCommand.self + ] + ) +} diff --git a/Sources/hpa/GenerateCommands/GenerateHtmlCommand.swift b/Sources/hpa/GenerateCommands/GenerateHtmlCommand.swift new file mode 100644 index 0000000..3f6bf39 --- /dev/null +++ b/Sources/hpa/GenerateCommands/GenerateHtmlCommand.swift @@ -0,0 +1,24 @@ +import ArgumentParser +import Dependencies +import PandocClient + +// TODO: Need to add a step to build prior to generating file. +struct GenerateHtmlCommand: AsyncParsableCommand { + static let commandName = "html" + + static let configuration = CommandConfiguration( + commandName: commandName + ) + + @OptionGroup var globals: GenerateOptions + + mutating func run() async throws { + @Dependency(\.pandocClient) var pandocClient + + let output = try await pandocClient.run.generateHtml( + globals.pandocRunOptions(commandName: Self.commandName) + ) + print(output) + } + +} diff --git a/Sources/hpa/GenerateCommands/GenerateLatexCommand.swift b/Sources/hpa/GenerateCommands/GenerateLatexCommand.swift new file mode 100644 index 0000000..5801bb0 --- /dev/null +++ b/Sources/hpa/GenerateCommands/GenerateLatexCommand.swift @@ -0,0 +1,22 @@ +import ArgumentParser +import Dependencies + +// TODO: Need to add a step to build prior to generating file. +struct GenerateLatexCommand: AsyncParsableCommand { + static let commandName = "latex" + + static let configuration = CommandConfiguration( + commandName: commandName + ) + + @OptionGroup var globals: GenerateOptions + + mutating func run() async throws { + @Dependency(\.pandocClient) var pandocClient + + let output = try await pandocClient.run.generateLatex( + globals.pandocRunOptions(commandName: Self.commandName) + ) + print(output) + } +} diff --git a/Sources/hpa/GenerateCommands/GenerateOptions.swift b/Sources/hpa/GenerateCommands/GenerateOptions.swift new file mode 100644 index 0000000..93598b5 --- /dev/null +++ b/Sources/hpa/GenerateCommands/GenerateOptions.swift @@ -0,0 +1,95 @@ +import ArgumentParser +import CommandClient +import PandocClient + +@dynamicMemberLookup +struct GenerateOptions: ParsableArguments { + + @OptionGroup var basic: BasicGlobalOptions + + @Option( + name: .shortAndLong, + help: "Custom build directory path.", + completion: .directory + ) + var buildDirectory: String? + + @Option( + name: [.short, .customLong("file")], + help: "Files used to generate the output, can be specified multiple times.", + completion: .file() + ) + var files: [String] = [] + + @Option( + name: [.customShort("H"), .long], + help: "Files to include in the header, can be specified multiple times.", + completion: .file() + ) + var includeInHeader: [String] = [] + + @Flag( + help: "Do not build the project prior to generating the output." + ) + var noBuild: Bool = false + + @Option( + name: .shortAndLong, + help: "The project directory.", + completion: .directory + ) + var projectDirectory: String? + + @Option( + name: .shortAndLong, + help: "The output directory", + completion: .directory + ) + var outputDirectory: String? + + @Option( + name: [.customShort("n"), .customLong("name")], + help: "Name of the output file." + ) + var outputFileName: String? + + // NOTE: This must be last, both here and in the commands, so if the commands have options of their + // own, they must be declared ahead of using the global options. + + @Argument( + help: "Extra arguments / options to pass to the underlying pandoc command." + ) + var extraOptions: [String] = [] + + subscript(dynamicMember keyPath: WritableKeyPath) -> T { + get { basic[keyPath: keyPath] } + set { basic[keyPath: keyPath] = newValue } + } + + subscript(dynamicMember keyPath: KeyPath) -> T { + basic[keyPath: keyPath] + } +} + +extension GenerateOptions { + + func loggingOptions(commandName: String) -> LoggingOptions { + basic.loggingOptions(commandName: commandName) + } + + func pandocRunOptions(commandName: String) -> PandocClient.RunOptions { + .init( + buildDirectory: buildDirectory, + extraOptions: extraOptions.count > 0 ? extraOptions : nil, + files: files.count > 0 ? files : nil, + loggingOptions: .init(commandName: commandName, logLevel: .init(globals: basic, quietOnlyPlaybook: false)), + includeInHeader: includeInHeader.count > 0 ? includeInHeader : nil, + outputDirectory: outputDirectory, + projectDirectory: projectDirectory, + outputFileName: outputFileName, + quiet: basic.quiet, + shell: basic.shell, + shouldBuild: !noBuild + ) + } +} diff --git a/Sources/hpa/GenerateCommands/GeneratePdfCommand.swift b/Sources/hpa/GenerateCommands/GeneratePdfCommand.swift new file mode 100644 index 0000000..2dfab9a --- /dev/null +++ b/Sources/hpa/GenerateCommands/GeneratePdfCommand.swift @@ -0,0 +1,32 @@ +import ArgumentParser +import Dependencies +import PandocClient + +// TODO: Need to add a step to build prior to generating file. + +struct GeneratePdfCommand: AsyncParsableCommand { + static let commandName = "pdf" + + static let configuration = CommandConfiguration( + commandName: commandName + ) + + @Option( + name: [.customShort("e"), .customLong("engine")], + help: "The pdf engine to use." + ) + var pdfEngine: String? + + @OptionGroup var globals: GenerateOptions + + mutating func run() async throws { + @Dependency(\.pandocClient) var pandocClient + + let output = try await pandocClient.run.generatePdf( + globals.pandocRunOptions(commandName: Self.commandName), + pdfEngine: pdfEngine + ) + + print(output) + } +} diff --git a/Sources/hpa/Internal/CommandConfigurationExtensions.swift b/Sources/hpa/Internal/CommandConfigurationExtensions.swift index 88545e9..724faca 100644 --- a/Sources/hpa/Internal/CommandConfigurationExtensions.swift +++ b/Sources/hpa/Internal/CommandConfigurationExtensions.swift @@ -4,66 +4,17 @@ import Rainbow extension CommandConfiguration { - static func create( - commandName: String, - abstract: String, - usesExtraArgs: Bool = true, - discussion: Discussion - ) -> Self { - .init( - commandName: commandName, - abstract: createAbstract(abstract), - discussion: discussion.render() - ) - } - - static func create( - commandName: String, - abstract: String, - usesExtraArgs: Bool = true, - discussion nodes: [Node] - ) -> Self { - .init( - commandName: commandName, - abstract: createAbstract(abstract), - discussion: Discussion(nodes: nodes, usesExtraArgs: usesExtraArgs).render() - ) - } - - static func playbook2( - commandName: String, - abstract: String, - parentCommand: String? = nil, - examples: (label: String, example: String)... - ) -> Self { - .init( - commandName: commandName, - abstract: Abstract { abstract.blue }, - usage: """ - \(Constants.appName)\(parentCommand != nil ? " \(parentCommand!)" : "") \(commandName) - """.blue.bold.italic - + " [OPTIONS]".green - + " [ARGUMENTS]".cyan - + " --" + " [EXTRA-OPTIONS...]".magenta, - discussion: CliDoc.Discussion.playbook(examples: examples) - ) - } - static func playbook( commandName: String, abstract: String, parentCommand: String? = nil, - examples: (label: String, example: String)... + examples: Example... ) -> Self { - .create( + .init( commandName: commandName, - abstract: abstract, - discussion: .default( - usesExtraArgs: true, - parentCommand: parentCommand, - examples: examples, - seeAlso: .seeAlso(label: "Ansible playbook options.", command: "ansible-playbook") - ) + abstract: Abstract { abstract.blue }, + usage: Usage.default(commandName: commandName, parentCommand: parentCommand), + discussion: Discussion.playbook(examples: examples) ) } } @@ -72,272 +23,39 @@ func createAbstract(_ string: String) -> String { "\(string.blue)" } -// TODO: Remove -struct Discussion { - let nodes: [Node] +extension Usage where Content == AnyTextNode { static func `default`( - usesExtraArgs: Bool, + commandName: String, parentCommand: String?, - examples: [(label: String, example: String)], - seeAlso: Node? + usesArguments: Bool = true, + usesOptions: Bool = true, + usesExtraArguments: Bool = true ) -> Self { - var nodes = Array.defaultNodes + examples.nodes(parentCommand) - if let seeAlso { nodes.append(seeAlso) } - if usesExtraArgs && examples.count > 0 { nodes.append(.important(label: Constants.importantExtraArgsNote)) } - return .init( - nodes: nodes, - usesExtraArgs: usesExtraArgs - ) - } - - init(usesExtraArgs: Bool = true, _ nodes: Node...) { - self.init(nodes: nodes, usesExtraArgs: usesExtraArgs) - } - - init(nodes: [Node], usesExtraArgs: Bool = true) { - var nodes = nodes - - if let firstExampleIndex = nodes.firstIndex(where: \.isExampleNode) { - nodes.insert(.exampleHeading, at: firstExampleIndex) - } - - if usesExtraArgs { - if let lastExampleIndex = nodes.lastIndex(where: \.isExampleNode) { - guard case let .example(.container(exampleNodes, _)) = nodes[lastExampleIndex] else { - // this should never happen. - print(nodes[lastExampleIndex]) - fatalError() + .init { + HStack { + HStack { + "\(Constants.appName)" + if let parentCommand { + parentCommand + } + commandName } + .color(.blue).bold() - // Get the last element of the example container, which will be the - // text for the example. - guard let exampleNode = exampleNodes.last else { - print(exampleNodes) - fatalError() + if usesOptions { + "[OPTIONS]".color(.green) + } + if usesArguments { + "[ARGUMENTS]".color(.cyan) + } + if usesExtraArguments { + "--" + "[EXTRA-OPTIONS]".color(.magenta) } - - // replace the first element, which is the header so that we can - // replace it with a new header. - nodes.insert( - .example(.exampleContainer( - "Passing extra args.", - "\(exampleNode.render().replacingOccurrences(of: " $ ", with: ""))" - + " -- --vault-id \"myId@$SCRIPTS/vault-gopass-client\"" - )), - at: nodes.index(after: lastExampleIndex) - ) } - } - self.nodes = nodes - } - func render() -> String { nodes.render() } -} - -enum Node { - - struct Separator { - - let string: String - let count: Int - - init(_ string: String, repeating count: Int = 1) { - self.string = string - self.count = count - } - - var value: String { string.repeating(count) } - - static var empty: Self { .init("") } - - static func space(_ count: Int = 1) -> Self { - .init(" ", repeating: count) - } - - static func newLine(_ count: Int = 1) -> Self { - .init("\n", repeating: count) - } - - static func tab(_ count: Int) -> Self { - .init("\t", repeating: count) - } - } - - /// A container that holds onto other nodes to be rendered. - indirect case container(_ nodes: [Node], separator: Separator = .newLine()) - - /// A container to identify example nodes, so some other nodes can get injected - /// when this is rendered. - /// - /// NOTE: Things blow up if this is not used correctly, at least when using the `Discussion` - /// to render the nodes, so this is not safe to create aside from the static methods that - /// create an example node. If not using in the context of a `Discussion` then it's fine to - /// use, as it doesn't do anything except stand as an type identifier. - indirect case example(_ node: Node) - - /// A root node that renders the given string without modification. - case text(_ text: String) - - static func container(separator: Separator = .newLine(), _ nodes: Node...) -> Self { - .container(nodes, separator: separator) - } - - static func container(_ lhs: Node, _ rhs: Node, separator: Separator = .newLine()) -> Self { - .container([lhs, rhs], separator: separator) - } - - static func styledText(_ text: String, color: NamedColor? = nil, styles: [Style]? = nil) -> Self { - var string = text - if let color { - string = string.applyingColor(color) - } - if let styles { - for style in styles { - string = string.applyingStyle(style) - } - } - return .text(string) - } - - static func styledText(_ text: String, color: NamedColor? = nil, style: Style) -> Self { - .styledText(text, color: color, styles: [style]) - } - - static func boldYellowText(_ text: String) -> Self { - .styledText(text, color: .yellow, styles: [.bold]) - } - - static func section(header: Node, content: Node, separator: Separator = .newLine()) -> Self { - .container([header, content], separator: separator) - } - - static func section(header: String, label: Node, separator: Separator = .newLine()) -> Self { - .section(header: .boldYellowText(header), content: label, separator: separator) - } - - static func important(label: String) -> Self { - .section( - header: .text("IMPORTANT NOTE:".red.bold.underline), - content: .text(label.italic), - separator: .newLine() - ) - } - - static func note(_ text: String, inline: Bool = true) -> Self { - .section(header: "NOTE:", label: .text(text.italic), separator: inline ? .space() : .newLine()) - } - - static func shellCommand(_ text: String, symbol: String = " $") -> Self { - .text("\(symbol) \(text)") - } - - static func seeAlso(label: String, command: String, appendHelpToCommand: Bool = true) -> Self { - .container([ - .container(.boldYellowText("See Also:"), .text(label.italic), separator: .space()), - .shellCommand("\(appendHelpToCommand ? command + " --help" : command)") - ]) - } - - static var exampleHeading: Self { - .section(header: "Examples:", label: .text("Some common examples."), separator: .space()) - } - - var isExampleNode: Bool { - if case .example = self { return true } - return false - } - - fileprivate static func exampleContainer(_ label: String, _ command: String) -> Self { - .container( - .exampleLabel("\(label)\n"), - .shellCommand(command, symbol: " $"), - separator: .empty - ) - } - - /// Renders an example usage of the command. - static func example(label: String, example: String, parentCommand: String?) -> Self { - let string: String - if let parent = parentCommand { - string = "\(Constants.appName) \(parent) \(example)" - } else { - string = "\(Constants.appName) \(example)" - } - return .example(.exampleContainer(label, string)) - } - - static func exampleLabel(_ label: String) -> Node { - .text(" \(label.green.italic)") - } - - func render() -> String { - switch self { - case let .container(nodes, separator): - return nodes.render(separator: separator) - case let .text(text): - return text - case let .example(node): - return node.render() + .eraseToAnyTextNode() } } } - -extension Node: ExpressibleByStringLiteral { - - typealias StringLiteralType = String - - init(stringLiteral value: String) { - self = .text(value) - } -} - -extension Node: ExpressibleByArrayLiteral { - - typealias ArrayLiteralElement = Node - - init(arrayLiteral elements: Node...) { - self = .container(elements) - } -} - -private extension Array where Element == (label: String, example: String) { - func nodes(_ parentCommand: String?) -> [Node] { - map { .example(label: $0.label, example: $0.example, parentCommand: parentCommand) } - } -} - -extension Array where Element == Node { - - static var defaultNodes: Self { - [.note("Most options are not required if you have a configuration file setup.")] - } - - fileprivate func render(separator: Node.Separator = .newLine()) -> String { - map { - // Strip of any new-line characters from the last section of the rendered string - // of the node. This allows us to have a consistent single new-line between each - // rendered node. - var string = $0.render()[...] - while string.hasSuffix("\n") { - string = string.dropLast() - } - return String(string) - } - .joined(separator: separator.value) - } -} - -private extension String { - func repeating(_ count: Int) -> Self { - guard count > 0 else { return self } - var count = count - var output = self - - while count > 0 { - output = "\(output)\(self)" - count -= 1 - } - return output - } -} diff --git a/Sources/hpa/Internal/Constants.swift b/Sources/hpa/Internal/Constants.swift index c35ea3f..c5d1433 100644 --- a/Sources/hpa/Internal/Constants.swift +++ b/Sources/hpa/Internal/Constants.swift @@ -3,6 +3,9 @@ import Rainbow // Constant string values. enum Constants { static let appName = "hpa" + static let brewPackages = [ + "ansible", "imagemagick", "pandoc", "texLive" + ] static let playbookFileName = "main.yml" static let inventoryFileName = "inventory.ini" static let importantExtraArgsNote = """ diff --git a/Sources/hpa/Internal/Discussion+playbook.swift b/Sources/hpa/Internal/Discussion+playbook.swift index c9ad0de..fff9ed5 100644 --- a/Sources/hpa/Internal/Discussion+playbook.swift +++ b/Sources/hpa/Internal/Discussion+playbook.swift @@ -1,86 +1,146 @@ import CliDoc -extension CliDoc.Discussion { +extension CliDoc.Discussion where Content == AnyTextNode { static func playbook( - examples: [(label: String, example: String)] + examples: [Example] ) -> Self { .init { - Section { + VStack { Note.mostOptionsNotRequired - Examples(examples: examples) + ExampleSection.default(examples: examples) SeeAlso(label: "Ansible playbook options.", command: "ansible-playbook --help") ImportantNote.passingExtraArgs } - .labeledContentStyle(.custom("foo:")) + .separator(.newLine(count: 2)) + .eraseToAnyTextNode() } } } -extension ShellCommand { +extension Array where Element == Example { + func addingPassingOptions(command: String) -> Self { + var output = self + output.append(( + label: "Passing extra arguments / options", + example: "\(command) -- --vaultId=myId@$SCRIPTS/vault-gopass-client" + )) + return output + } + + func addingPipeToOtherCommand(command: String) -> Self { + var output = self + output.append(( + label: "Piping output to other command.", + example: "\(command) --quiet | xargs open" + )) + return output + } +} + +extension ExampleSection where Header == String, Label == String { + static func `default`( + examples: [Example], + usesPassingExtraOptions: Bool = true, + usesPipeToOtherCommand: Bool = true + ) -> some TextNode { + var examples = examples + + if let first = examples.first { + if usesPassingExtraOptions { + examples = examples.addingPassingOptions(command: first.example) + } + if usesPipeToOtherCommand { + examples = examples.addingPipeToOtherCommand(command: first.example) + } + } + return Self(examples: examples) { + "EXAMPLES:" + } label: { + "Some common usage examples." + } + .exampleStyle(HPAExampleStyle()) + } +} + +struct HPAExampleStyle: ExampleStyle { + + func render(content: ExampleConfiguration) -> some TextNode { + VStack { + content.examples.map { example in + VStack { + example.label.color(.green).bold() + ShellCommand.hpaCommand(example.example) + } + } + } + .separator(.newLine(count: 2)) + } +} + +extension ShellCommand where Content == String, Symbol == String { static func hpaCommand(_ command: String) -> Self { - .init(command: "\(Constants.appName) \(command)") + .init { "\(Constants.appName) \(command)" } symbol: { "$" } } } -struct SeeAlso: NodeRepresentable { +struct SeeAlso: TextNode { - let node: any NodeRepresentable - - init(label: String, command: String) { - self.node = Group(separator: "\n") { - Note("SEE ALSO:") { - label - } - ShellCommand(command: command) - } - } - - func render() -> String { - node.render() - } - -} - -struct Examples: NodeRepresentable { - typealias Example = (label: String, example: String) - let examples: [Example] - - func render() -> String { - Group(separator: "\n") { - Note("EXAMPLES:") { "Common usage examples." } - for (label, command) in examples { - LabeledContent("\t\(label.green.bold)", separator: "\n") { - ShellCommand.hpaCommand(command) - } - } - if let first = examples.first { - LabeledContent("\n\tPassing extra options.".green.bold, separator: "\n") { - ShellCommand.hpaCommand("\(first.example) -- --vault-id \"myId@$SCRIPTS/vault-gopass-client\"") - } - } - }.render() - } -} - -struct Note: NodeRepresentable { - - let node: any NodeRepresentable + let title = "SEE ALSO:" + let label: Label + let content: Content init( - _ label: String = "NOTE:", - @NodeBuilder _ content: () -> any NodeRepresentable + @TextBuilder content: () -> Content, + @TextBuilder label: () -> Label ) { - self.node = LabeledContent( - separator: " ", - label: { label.yellow.bold }, - content: { content().style(.italic) } - ) + self.content = content() + self.label = label() } - func render() -> String { - node.render() + var body: some TextNode { + VStack { + LabeledContent { + label.italic() + } label: { + title.color(.yellow).bold().underline() + } + ShellCommand { content } symbol: { "$" } + } + } +} + +extension SeeAlso where Content == String, Label == Empty { + init(command: String) { + self.init { "\(Constants.appName) \(command)" } label: { Empty() } + } +} + +extension SeeAlso where Content == String, Label == String { + init(label: String, command: String) { + self.init { "\(Constants.appName) \(command)" } label: { label } + } +} + +struct Note: TextNode { + + let label: String = "NOTE:" + let content: Content + + init( + @TextBuilder _ content: () -> Content + ) { + self.content = content() } + var body: some TextNode { + HStack { + label.color(.yellow).bold() + content + } + } +} + +extension Note where Content == String { static var mostOptionsNotRequired: Self { .init { "Most options are not required if you have a configuration file setup." @@ -88,26 +148,27 @@ struct Note: NodeRepresentable { } } -// TODO: Fix the text. -struct ImportantNote: NodeRepresentable { +struct ImportantNote: TextNode { - let node: any NodeRepresentable + let label: String = "IMPORTANT NOTE:" + let content: Content init( - _ label: String = "IMPORTANT NOTE:", - @NodeBuilder _ content: () -> any NodeRepresentable + @TextBuilder _ content: () -> Content ) { - self.node = LabeledContent( - separator: "\n", - label: { label.red.bold.underline }, - content: { content().style(.italic) } - ) + self.content = content() } - func render() -> String { - node.render() + var body: some TextNode { + HStack { + label.color(.red).bold().underline() + content + } } +} + +extension ImportantNote where Content == String { static var passingExtraArgs: Self { .init { """ diff --git a/Sources/hpa/Internal/GlobalOptions.swift b/Sources/hpa/Internal/GlobalOptions.swift index c33a253..f90710e 100644 --- a/Sources/hpa/Internal/GlobalOptions.swift +++ b/Sources/hpa/Internal/GlobalOptions.swift @@ -1,4 +1,7 @@ import ArgumentParser +import CommandClient +import ConfigurationClient +import PlaybookClient struct BasicGlobalOptions: ParsableArguments { @Flag( @@ -26,10 +29,10 @@ struct BasicGlobalOptions: ParsableArguments { struct GlobalOptions: ParsableArguments { @Option( - name: .shortAndLong, + name: .long, help: "Optional path to the ansible hpa playbook directory." ) - var playbookDir: String? + var playbookDirectory: String? @Option( name: .shortAndLong, @@ -55,3 +58,39 @@ struct GlobalOptions: ParsableArguments { } } + +extension GlobalOptions { + func loggingOptions(commandName: String) -> LoggingOptions { + .init( + commandName: commandName, + logLevel: .init(globals: basic, quietOnlyPlaybook: quietOnlyPlaybook) + ) + } +} + +extension BasicGlobalOptions { + func loggingOptions(commandName: String) -> LoggingOptions { + .init( + commandName: commandName, + logLevel: .init(globals: self, quietOnlyPlaybook: false) + ) + } +} + +extension GlobalOptions { + func sharedPlaybookRunOptions( + commandName: String, + extraOptions: [String]? + ) -> PlaybookClient.RunPlaybook.SharedRunOptions { + return .init( + extraOptions: extraOptions, + inventoryFilePath: inventoryPath, + loggingOptions: .init( + commandName: commandName, + logLevel: .init(globals: basic, quietOnlyPlaybook: quietOnlyPlaybook) + ), + quiet: basic.quiet, + shell: basic.shell + ) + } +} diff --git a/Sources/hpa/Internal/LoggingExtensions.swift b/Sources/hpa/Internal/LoggingExtensions.swift index d749bad..6c37677 100644 --- a/Sources/hpa/Internal/LoggingExtensions.swift +++ b/Sources/hpa/Internal/LoggingExtensions.swift @@ -1,4 +1,3 @@ -import Dependencies import Logging extension Logger.Level { @@ -21,34 +20,3 @@ extension Logger.Level { self = .info } } - -func withSetupLogger( - commandName: String, - globals: BasicGlobalOptions, - quietOnlyPlaybook: Bool = false, - dependencies setupDependencies: (inout DependencyValues) -> Void = { _ in }, - operation: @escaping () async throws -> Void -) async rethrows { - try await withDependencies { - $0.logger = .init(label: "\("hpa".yellow)") - $0.logger.logLevel = .init(globals: globals, quietOnlyPlaybook: quietOnlyPlaybook) - $0.logger[metadataKey: "command"] = "\(commandName.blue)" - } operation: { - try await operation() - } -} - -func withSetupLogger( - commandName: String, - globals: GlobalOptions, - dependencies setupDependencies: (inout DependencyValues) -> Void = { _ in }, - operation: @escaping () async throws -> Void -) async rethrows { - try await withSetupLogger( - commandName: commandName, - globals: globals.basic, - quietOnlyPlaybook: globals.quietOnlyPlaybook, - dependencies: setupDependencies, - operation: operation - ) -} diff --git a/Sources/hpa/Internal/RunPlaybook.swift b/Sources/hpa/Internal/RunPlaybook.swift deleted file mode 100644 index ccdfce7..0000000 --- a/Sources/hpa/Internal/RunPlaybook.swift +++ /dev/null @@ -1,108 +0,0 @@ -import ArgumentParser -import CliClient -import Dependencies -import Foundation -import Logging -import Rainbow -import ShellClient - -func runPlaybook( - commandName: String, - globals: GlobalOptions, - configuration: Configuration? = nil, - extraArgs: [String], - _ args: [String] -) async throws { - try await withSetupLogger(commandName: commandName, globals: globals) { - @Dependency(\.cliClient) var cliClient - @Dependency(\.logger) var logger - - logger.debug("Begin run playbook: \(globals)") - - let configuration = try cliClient.ensuredConfiguration(configuration) - - logger.debug("Configuration: \(configuration)") - - let playbookDir = try ensureString( - globals: globals, - configuration: configuration, - globalsKeyPath: \.playbookDir, - configurationKeyPath: \.playbookDir - ) - let playbook = "\(playbookDir)/\(Constants.playbookFileName)" - - let inventory = (try? ensureString( - globals: globals, - configuration: configuration, - globalsKeyPath: \.inventoryPath, - configurationKeyPath: \.inventoryPath - )) ?? "\(playbookDir)/\(Constants.inventoryFileName)" - - let defaultArgs = (configuration.defaultPlaybookArgs ?? []) - + (configuration.defaultVaultArgs ?? []) - - try await cliClient.runCommand( - quiet: globals.quietOnlyPlaybook ? true : globals.quiet, - shell: globals.shellOrDefault, - [ - "ansible-playbook", playbook, - "--inventory", inventory - ] + args - + extraArgs - + defaultArgs - ) - } -} - -func runPlaybook( - commandName: String, - globals: GlobalOptions, - configuration: Configuration? = nil, - extraArgs: [String], - _ args: String... -) async throws { - try await runPlaybook( - commandName: commandName, - globals: globals, - configuration: configuration, - extraArgs: extraArgs, - args - ) -} - -extension CliClient { - func ensuredConfiguration(_ configuration: Configuration?) throws -> Configuration { - guard let configuration else { - return try loadConfiguration() - } - return configuration - } -} - -extension BasicGlobalOptions { - - var shellOrDefault: ShellCommand.Shell { - guard let shell else { return .zsh(useDashC: true) } - return .custom(path: shell, useDashC: true) - } -} - -func ensureString( - globals: GlobalOptions, - configuration: Configuration, - globalsKeyPath: KeyPath, - configurationKeyPath: KeyPath -) throws -> String { - if let global = globals[keyPath: globalsKeyPath] { - return global - } - guard let configuration = configuration[keyPath: configurationKeyPath] else { - throw RunPlaybookError.playbookNotFound - } - return configuration -} - -enum RunPlaybookError: Error { - case playbookNotFound - case configurationError -} diff --git a/Sources/hpa/Internal/RunVault.swift b/Sources/hpa/Internal/RunVault.swift deleted file mode 100644 index 39cb5ed..0000000 --- a/Sources/hpa/Internal/RunVault.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Dependencies -import ShellClient - -func runVault( - commandName: String, - options: VaultOptions, - _ args: [String] -) async throws { - try await withSetupLogger(commandName: commandName, globals: options.globals) { - @Dependency(\.cliClient) var cliClient - @Dependency(\.logger) var logger - - logger.debug("Begin run vault: \(options)") - - let configuration = try cliClient.ensuredConfiguration(nil) - logger.debug("Configuration: \(configuration)") - - let path: String - if let file = options.file { - path = file - } else { - path = try cliClient.findVaultFileInCurrentDirectory() - } - - logger.debug("Vault path: \(path)") - - let defaultArgs = configuration.defaultVaultArgs ?? [] - - var vaultArgs = ["ansible-vault"] - + args - + defaultArgs - + options.extraArgs - + [path] - - if args.contains("encrypt"), - !vaultArgs.contains("--encrypt-vault-id"), - let id = configuration.defaultVaultEncryptId - { - vaultArgs.append(contentsOf: ["--encrypt-vault-id", id]) - } - - try await cliClient.runCommand( - quiet: options.quiet, - shell: options.shellOrDefault, - vaultArgs - ) - } -} diff --git a/Sources/hpa/UtilsCommands/CreateTemplateCommand.swift b/Sources/hpa/UtilsCommands/CreateTemplateCommand.swift deleted file mode 100644 index 7fb51d5..0000000 --- a/Sources/hpa/UtilsCommands/CreateTemplateCommand.swift +++ /dev/null @@ -1,57 +0,0 @@ -import ArgumentParser - -struct CreateProjectTemplateCommand: AsyncParsableCommand { - - static let commandName = "create-template" - - static let configuration = CommandConfiguration.playbook( - commandName: commandName, - abstract: "Create a home performance assesment project template.", - parentCommand: UtilsCommand.commandName, - examples: (label: "Create Template", example: "\(commandName) /path/to/project-template") - ) - - @OptionGroup var globals: GlobalOptions - - @Option( - name: .shortAndLong, - help: "Customize the directory where template variables are stored." - ) - var templateVars: String? - - @Flag( - name: .long, - help: "Do not generate ansible-vault variables file." - ) - var noVault: Bool = false - - @Argument( - help: "Path to the project template directory.", - completion: .directory - ) - var path: String - - @Argument( - help: "Extra arguments passed to the playbook." - ) - var extraArgs: [String] = [] - - mutating func run() async throws { - let varsDir = templateVars != nil - ? ["--extra-vars", "repo_vars_dir=\(templateVars!)"] - : [] - - let vault = noVault ? ["--extra-vars", "use_vault=false"] : [] - - try await runPlaybook( - commandName: Self.commandName, - globals: globals, - extraArgs: extraArgs, - [ - "--tags", "repo-template", - "--extra-vars", "output_dir=\(path)" - ] + varsDir - + vault - ) - } -} diff --git a/Sources/hpa/UtilsCommands/DumpConfigCommand.swift b/Sources/hpa/UtilsCommands/DumpConfigCommand.swift new file mode 100644 index 0000000..2336c25 --- /dev/null +++ b/Sources/hpa/UtilsCommands/DumpConfigCommand.swift @@ -0,0 +1,27 @@ +import ArgumentParser +import CliDoc +import ConfigurationClient +import CustomDump +import Dependencies + +struct DumpConfigCommand: AsyncParsableCommand { + static let commandName = "dump-config" + + static let configuration = CommandConfiguration( + commandName: commandName, + abstract: createAbstract("Show the current configuration."), + usage: .default(commandName: commandName, parentCommand: "utils", usesArguments: false, usesExtraArguments: false), + discussion: Discussion { + "Useful to debug your configuration settings / make sure they load properly." + } + ) + + func run() async throws { + @Dependency(\.configurationClient) var configurationClient + + let configuration = try await configurationClient.findAndLoad() + + customDump(configuration) + } + +} diff --git a/Sources/hpa/UtilsCommands/GenerateConfigCommand.swift b/Sources/hpa/UtilsCommands/GenerateConfigCommand.swift index f7fc403..096acd8 100644 --- a/Sources/hpa/UtilsCommands/GenerateConfigCommand.swift +++ b/Sources/hpa/UtilsCommands/GenerateConfigCommand.swift @@ -1,5 +1,7 @@ import ArgumentParser -import CliClient +import CliDoc +import CommandClient +import ConfigurationClient import Dependencies struct GenerateConfigurationCommand: AsyncParsableCommand { @@ -9,18 +11,23 @@ struct GenerateConfigurationCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: commandName, abstract: createAbstract("Generate a local configuration file."), - discussion: """ - - \("NOTE:".yellow) If a directory is not supplied then a configuration file will be created - at \("'~/.config/hpa-playbook/config'".yellow). - - \("Example:".yellow) - - \("Create a directory and generate the configuration file.".green) - $ mkdir -p ~/.config/hpa-playbook - $ hpa generate-config --path ~/.config/hpa-playbook - - """ + discussion: Discussion { + VStack { + Note { + """ + If a directory is not supplied then a configuration file will be created + at \("'~/.config/hpa/config.toml'".yellow). + """ + } + VStack { + "EXAMPLE:".yellow.bold + "Create a directory and generate the configuration".green + ShellCommand("mkdir -p ~/.config/hpa") + ShellCommand("hpa generate-config --path ~/.config/hpa") + } + } + .separator(.newLine(count: 2)) + } ) @Option( @@ -32,10 +39,16 @@ struct GenerateConfigurationCommand: AsyncParsableCommand { @Flag( name: .shortAndLong, - help: "Generate a json file, instead of default env style" + help: "Generate a json file, instead of the default toml style" ) var json: Bool = false + @Flag( + name: .shortAndLong, + help: "Force generation, overwriting a file if it exists." + ) + var force: Bool = false + @OptionGroup var globals: BasicGlobalOptions mutating func run() async throws { @@ -43,24 +56,18 @@ struct GenerateConfigurationCommand: AsyncParsableCommand { } private func _run() async throws { - try await withSetupLogger(commandName: Self.commandName, globals: globals) { - @Dependency(\.cliClient) var cliClient + @Dependency(\.configurationClient) var configurationClient - let actualPath: String + try await globals.loggingOptions(commandName: Self.commandName).withLogger { + @Dependency(\.logger) var logger - if let path { - actualPath = "\(path)/config" - } else { - let path = "~/.config/hpa-playbook/" - try await cliClient.runCommand( - quiet: false, - shell: globals.shellOrDefault, - "mkdir", "-p", path - ) - actualPath = "\(path)/config" - } + let output = try await configurationClient.generate(.init( + force: force, + json: json, + path: path != nil ? .directory(path!) : nil + )) - try cliClient.createConfiguration(actualPath, json) + print(output) } } } diff --git a/Sources/hpa/UtilsCommands/GenerateProjectTemplateCommand.swift b/Sources/hpa/UtilsCommands/GenerateProjectTemplateCommand.swift new file mode 100644 index 0000000..2f12311 --- /dev/null +++ b/Sources/hpa/UtilsCommands/GenerateProjectTemplateCommand.swift @@ -0,0 +1,56 @@ +import ArgumentParser +import Dependencies +import PlaybookClient + +struct GenerateProjectTemplateCommand: AsyncParsableCommand { + + static let commandName = "generate-template" + + static let configuration = CommandConfiguration.playbook( + commandName: commandName, + abstract: "Generate a home performance assesment project template.", + parentCommand: UtilsCommand.commandName, + examples: (label: "Generate Template", example: "\(commandName) /path/to/project-template") + ) + + @OptionGroup var globals: GlobalOptions + + @Option( + name: .shortAndLong, + help: "Customize the directory where template variables are stored." + ) + var templateVars: String? + + @Flag( + name: .long, + help: "Do not generate ansible-vault variables file." + ) + var noVault: Bool = false + + @Argument( + help: "Path to the project template directory.", + completion: .directory + ) + var path: String + + @Argument( + help: "Extra arguments / options passed to the playbook." + ) + var extraOptions: [String] = [] + + mutating func run() async throws { + @Dependency(\.playbookClient) var playbookClient + + let output = try await playbookClient.run.generateTemplate(.init( + shared: globals.sharedPlaybookRunOptions( + commandName: Self.commandName, + extraOptions: extraOptions + ), + templateDirectory: path, + templateVarsDirectory: templateVars, + useVault: !noVault + )) + + print(output) + } +} diff --git a/Sources/hpa/UtilsCommands/InstallDependenciesCommand.swift b/Sources/hpa/UtilsCommands/InstallDependenciesCommand.swift new file mode 100644 index 0000000..13df49c --- /dev/null +++ b/Sources/hpa/UtilsCommands/InstallDependenciesCommand.swift @@ -0,0 +1,52 @@ +import ArgumentParser +import CliDoc +import CommandClient +import Dependencies + +struct InstallDependenciesCommand: AsyncParsableCommand { + static let commandName: String = "install-dependencies" + + static let configuration = CommandConfiguration( + commandName: commandName, + abstract: createAbstract("Ensure required dependencies are installed"), + usage: .default(commandName: commandName, parentCommand: "utils", usesArguments: false), + discussion: Discussion { + VStack { + Note { + "Homebrew is required to install dependencies." + } + HStack { + "See Also:".yellow.bold.underline + "https://brew.sh" + } + ImportantNote.passingExtraArgs + } + .separator(.newLine(count: 2)) + } + ) + + @OptionGroup var globals: BasicGlobalOptions + + @Argument( + help: "Extra arguments / options to pass to the homebrew command." + ) + var extraOptions: [String] = [] + + mutating func run() async throws { + @Dependency(\.commandClient) var commandClient + @Dependency(\.playbookClient) var playbookClient + + let arguments = [ + "brew", "install" + ] + Constants.brewPackages + + extraOptions + + try await commandClient.run( + quiet: globals.quiet, + shell: globals.shell, + arguments + ) + + try await playbookClient.repository.install() + } +} diff --git a/Sources/hpa/UtilsCommands/UtilsCommand.swift b/Sources/hpa/UtilsCommands/UtilsCommand.swift index 3e79de3..07a6e00 100644 --- a/Sources/hpa/UtilsCommands/UtilsCommand.swift +++ b/Sources/hpa/UtilsCommands/UtilsCommand.swift @@ -5,12 +5,15 @@ struct UtilsCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: commandName, - abstract: "\("Utility commands.".blue)", + abstract: createAbstract("Utility commands."), discussion: """ These are commands that are generally only run on occasion / less frequently used. """, subcommands: [ - CreateProjectTemplateCommand.self, GenerateConfigurationCommand.self + DumpConfigCommand.self, + GenerateProjectTemplateCommand.self, + GenerateConfigurationCommand.self, + InstallDependenciesCommand.self ] ) diff --git a/Sources/hpa/VaultCommands/DecryptCommand.swift b/Sources/hpa/VaultCommands/DecryptCommand.swift index 95c8124..149ece5 100644 --- a/Sources/hpa/VaultCommands/DecryptCommand.swift +++ b/Sources/hpa/VaultCommands/DecryptCommand.swift @@ -1,4 +1,6 @@ import ArgumentParser +import Dependencies +import VaultClient struct DecryptCommand: AsyncParsableCommand { @@ -9,23 +11,22 @@ struct DecryptCommand: AsyncParsableCommand { abstract: createAbstract("Decrypt a vault file.") ) - @OptionGroup var options: VaultOptions - @Option( name: .shortAndLong, help: "Output file." ) var output: String? + @OptionGroup var options: VaultOptions + mutating func run() async throws { - var args = ["decrypt"] - if let output { - args.append(contentsOf: ["--output", output]) - } - try await runVault( + @Dependency(\.vaultClient) var vaultClient + + let output = try await vaultClient.run.decrypt(options.runOptions( commandName: Self.commandName, - options: options, - args - ) + outputFilePath: output + )) + + print(output) } } diff --git a/Sources/hpa/VaultCommands/EditCommand.swift b/Sources/hpa/VaultCommands/EditCommand.swift new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Sources/hpa/VaultCommands/EditCommand.swift @@ -0,0 +1 @@ + diff --git a/Sources/hpa/VaultCommands/EncryptCommand.swift b/Sources/hpa/VaultCommands/EncryptCommand.swift index 2d24a2a..952290e 100644 --- a/Sources/hpa/VaultCommands/EncryptCommand.swift +++ b/Sources/hpa/VaultCommands/EncryptCommand.swift @@ -1,4 +1,6 @@ import ArgumentParser +import Dependencies +import VaultClient struct EncryptCommand: AsyncParsableCommand { @@ -9,23 +11,22 @@ struct EncryptCommand: AsyncParsableCommand { abstract: createAbstract("Encrypt a vault file.") ) - @OptionGroup var options: VaultOptions - @Option( name: .shortAndLong, help: "Output file." ) var output: String? + @OptionGroup var options: VaultOptions + mutating func run() async throws { - var args = ["encrypt"] - if let output { - args.append(contentsOf: ["--output", output]) - } - try await runVault( + @Dependency(\.vaultClient) var vaultClient + + let output = try await vaultClient.run.encrypt(options.runOptions( commandName: Self.commandName, - options: options, - args - ) + outputFilePath: output + )) + + print(output) } } diff --git a/Sources/hpa/VaultCommands/VaultCommand.swift b/Sources/hpa/VaultCommands/VaultCommand.swift index 380e6a5..f20003b 100644 --- a/Sources/hpa/VaultCommands/VaultCommand.swift +++ b/Sources/hpa/VaultCommands/VaultCommand.swift @@ -1,4 +1,5 @@ import ArgumentParser +import CliDoc struct VaultCommand: AsyncParsableCommand { @@ -7,14 +8,23 @@ struct VaultCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: commandName, abstract: createAbstract("Vault commands."), - discussion: Discussion( - .text(""" - Allows you to run `ansible-vault` commands on your project or project-template. - """), - .seeAlso(label: "Ansible Vault", command: "ansible-vault") - ).render(), + discussion: Discussion { + VStack { + """ + Allows you to run `ansible-vault` commands on your project or project-template. + + Ansible-vault allows you to store sensitive variables in an encrypted format. + """ + SeeAlso { + "ansible-vault --help" + } label: { + "Ansible Vault" + } + } + .separator(.newLine(count: 2)) + }, subcommands: [ - EncryptCommand.self, DecryptCommand.self + DecryptCommand.self, EncryptCommand.self ] ) } diff --git a/Sources/hpa/VaultCommands/VaultOptions.swift b/Sources/hpa/VaultCommands/VaultOptions.swift index bce4c9d..dc3cf37 100644 --- a/Sources/hpa/VaultCommands/VaultOptions.swift +++ b/Sources/hpa/VaultCommands/VaultOptions.swift @@ -1,4 +1,6 @@ import ArgumentParser +import CommandClient +import VaultClient // Holds the common options for vault commands, as they all share the // same structure. @@ -17,7 +19,7 @@ struct VaultOptions: ParsableArguments { @Argument( help: "Extra arguments to pass to the vault command." ) - var extraArgs: [String] = [] + var extraOptions: [String] = [] subscript(dynamicMember keyPath: WritableKeyPath) -> T { get { globals[keyPath: keyPath] } @@ -29,3 +31,23 @@ struct VaultOptions: ParsableArguments { } } + +extension VaultOptions { + + func runOptions( + commandName: String, + outputFilePath: String? = nil + ) -> VaultClient.RunOptions { + .init( + extraOptions: extraOptions, + loggingOptions: .init( + commandName: commandName, + logLevel: .init(globals: globals, quietOnlyPlaybook: false) + ), + outputFilePath: outputFilePath, + quiet: globals.quiet, + shell: globals.shell, + vaultFilePath: file + ) + } +} diff --git a/Sources/hpa/Version.swift b/Sources/hpa/Version.swift new file mode 100644 index 0000000..c5554ab --- /dev/null +++ b/Sources/hpa/Version.swift @@ -0,0 +1,2 @@ +// Do not set this variable, it is set during the build process. +let VERSION: String? = nil diff --git a/Tests/CliClientTests/CliClientTests.swift b/Tests/CliClientTests/CliClientTests.swift deleted file mode 100644 index 0597d6a..0000000 --- a/Tests/CliClientTests/CliClientTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -@_spi(Internal) import CliClient -import Dependencies -import ShellClient -import Testing - -@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) - } -} - -@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) - } -} - -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() - } -} 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) - } -} diff --git a/Tests/ConfigurationClientTests/ConfigurationClientTests.swift b/Tests/ConfigurationClientTests/ConfigurationClientTests.swift new file mode 100644 index 0000000..c998e0e --- /dev/null +++ b/Tests/ConfigurationClientTests/ConfigurationClientTests.swift @@ -0,0 +1,201 @@ +@_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.coders = .liveValue + $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) + let output = try await configuration.generate(.init( + force: false, + json: fileName.hasSuffix("json"), + path: .file(File(tempFile)!) + )) + #expect(FileManager.default.fileExists(atPath: tempFile.cleanFilePath)) + #expect(fileClient.fileExists(tempFile)) + #expect(output == tempFile.cleanFilePath) + } + } + } + + @Test(arguments: ["config.toml", "config.json", nil]) + func loadConfigFile(fileName: String?) async throws { + try await withTestLogger(key: "generateConfigFile") { + $0.coders = .liveValue + $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)) + } + } + } + } + + @Test + func writeCreatesBackupFile() async throws { + try await withDependencies { + $0.fileClient = .liveValue + } operation: { + let client = ConfigurationClient.liveValue + + try await withGeneratedConfigFile(named: "config.toml", client: client) { configFile in + @Dependency(\.fileClient) var fileClient + + let backupUrl = configFile.url.appendingPathExtension("back") + #expect(fileClient.fileExists(backupUrl) == false) + + let config = Configuration() + try await client.write(config, to: configFile) + + #expect(fileClient.fileExists(backupUrl)) + } + } + } +} + +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] + ] +} + +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(.init( + force: true, + json: fileName.hasSuffix("json"), + path: .file(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(.init( + force: true, + json: fileName.hasSuffix("json"), + path: .file(file) + )) + try await operation(file, tempDir) + } +} diff --git a/Tests/FileClientTests/FileClientTests.swift b/Tests/FileClientTests/FileClientTests.swift new file mode 100644 index 0000000..2b77fa3 --- /dev/null +++ b/Tests/FileClientTests/FileClientTests.swift @@ -0,0 +1,67 @@ +import FileClient +import Foundation +import Testing +import TestSupport + +@Suite("FileClientTests") +struct FileClientTests { + + @Test + func createDirectory() async throws { + try await withTemporaryDirectory { url in + let fileClient = FileClient.liveValue + let tempDir = url.appending(path: "temp") + try await fileClient.createDirectory(tempDir) + let isDirectory = try await fileClient.isDirectory(tempDir) + #expect(isDirectory) + } + } + + @Test(arguments: ["vault.yml", "vault.yaml"]) + func findVaultFile(fileName: String) async throws { + try await withTemporaryDirectory { url in + let fileClient = FileClient.liveValue + + let vaultFilePath = url.appending(path: fileName) + FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil) + let output = try await fileClient.findVaultFile(url)! + + #expect(output.cleanFilePath == vaultFilePath.cleanFilePath) + + let nilWhenFileNotDirectory = try await fileClient.findVaultFile(vaultFilePath) + #expect(nilWhenFileNotDirectory == nil) + } + } + + @Test(arguments: ["vault.yml", "vault.yaml"]) + func findVaultFileNestedInSubfolder(fileName: String) async throws { + try await withTemporaryDirectory { url in + + let fileClient = FileClient.liveValue + let subDir = url.appending(path: "sub") + + try await fileClient.createDirectory(subDir) + + let vaultFilePath = subDir.appending(path: fileName) + FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil) + let output = try await fileClient.findVaultFile(url)! + + #expect(output.cleanFilePath == vaultFilePath.cleanFilePath) + } + } + + @Test + func findVaultFileReturnsNil() async throws { + try await withTemporaryDirectory { url in + + let fileClient = FileClient.liveValue + let subDir = url.appending(path: "sub") + + try await fileClient.createDirectory(subDir) + + let output = try await fileClient.findVaultFile(url) + + #expect(output == nil) + } + } +} diff --git a/Tests/PandocClientTests/PandocClientTests.swift b/Tests/PandocClientTests/PandocClientTests.swift new file mode 100644 index 0000000..9537c28 --- /dev/null +++ b/Tests/PandocClientTests/PandocClientTests.swift @@ -0,0 +1,333 @@ +@_spi(Internal) import ConfigurationClient +@_spi(Internal) import PandocClient +import PlaybookClient +import Testing +import TestSupport + +@Suite("PandocClientTests") +struct PandocClientTests: TestCase { + + static let outputDirectory = "/output" + static let projectDirectory = "/project" + static let defaultFileName = "Report" + + static let expectedIncludeInHeaders = [ + "--include-in-header=/project/.build/head.tex", + "--include-in-header=/project/.build/footer.tex" + ] + + static let expectedFiles = [ + "/project/.build/Report.md", + "/project/.build/Definitions.md" + ] + + static var sharedRunOptions: PandocClient.RunOptions { + .init( + buildDirectory: nil, + files: nil, + loggingOptions: loggingOptions, + includeInHeader: nil, + outputDirectory: outputDirectory, + projectDirectory: projectDirectory, + outputFileName: nil, + quiet: false, + shell: nil, + shouldBuild: true + ) + } + + @Test + func generateLatex() async throws { + try await withCapturingCommandClient("generateLatex") { + $0.configurationClient = .mock() + $0.playbookClient.run.buildProject = { _ in } + $0.pandocClient = .liveValue + } run: { + @Dependency(\.pandocClient) var pandocClient + + let output = try await pandocClient.run.generateLatex(Self.sharedRunOptions) + #expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).tex") + + } assert: { output in + let expected = ["pandoc"] + + Self.expectedIncludeInHeaders + + ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).tex"] + + Self.expectedFiles + + #expect(output.arguments == expected) + } + } + + @Test + func generateHtml() async throws { + try await withCapturingCommandClient("generateHtml") { + $0.configurationClient = .mock() + $0.playbookClient.run.buildProject = { _ in } + $0.pandocClient = .liveValue + } run: { + @Dependency(\.pandocClient) var pandocClient + + let output = try await pandocClient.run.generateHtml(Self.sharedRunOptions) + #expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).html") + + } assert: { output in + let expected = ["pandoc"] + + Self.expectedIncludeInHeaders + + ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).html"] + + Self.expectedFiles + + #expect(output.arguments == expected) + } + } + + @Test( + arguments: [ + nil, + "lualatex" + ] + ) + func generatePdf(pdfEngine: String?) async throws { + try await withCapturingCommandClient("generatePdf") { + $0.configurationClient = .mock() + $0.playbookClient.run.buildProject = { _ in } + $0.pandocClient = .liveValue + } run: { + @Dependency(\.pandocClient) var pandocClient + + let output = try await pandocClient.run.generatePdf(Self.sharedRunOptions, pdfEngine: pdfEngine) + #expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).pdf") + + } assert: { output in + let expected = ["pandoc"] + + Self.expectedIncludeInHeaders + + ["--pdf-engine=\(pdfEngine ?? "xelatex")"] + + ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).pdf"] + + Self.expectedFiles + + #expect(output.arguments == expected) + } + } + + @Test(arguments: TestPdfEngine.testCases) + func parsePdfEngine(input: TestPdfEngine) { + #expect(input.engine == input.expectedEngine) + } + + @Test(arguments: TestParseFiles.testCases) + func parseFiles(input: TestParseFiles) { + #expect(input.parsedFiles == input.expectedFiles) + } + + @Test(arguments: TestParseIncludeInHeaderFiles.testCases) + func parseInclueInHeaderFiles(input: TestParseIncludeInHeaderFiles) { + #expect(input.parsedFiles == input.expectedHeaderFiles) + } + + @Test(arguments: TestParseOutputFileName.testCases) + func parseOutputFileName(input: TestParseOutputFileName) { + #expect(input.parsedFileName == input.expected) + } + + @Test(arguments: TestParseBuildDirectory.testCases) + func parseBuildDirectory(input: TestParseBuildDirectory) { + #expect(input.parsedBuildDirectory == input.expected) + } +} + +struct TestPdfEngine: Sendable { + let fileType: PandocClient.FileType + let expectedEngine: String? + let configuration: Configuration + let defaults: Configuration.Generate + + var engine: String? { + fileType.parsePdfEngine(configuration.generate, defaults) + } + + static let testCases: [Self] = [ + .init(fileType: .html, expectedEngine: nil, configuration: .init(), defaults: .default), + .init(fileType: .latex, expectedEngine: nil, configuration: .init(), defaults: .default), + .init(fileType: .pdf(engine: "lualatex"), expectedEngine: "lualatex", configuration: .init(), defaults: .default), + .init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(), defaults: .default), + .init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(), defaults: .init()), + .init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(generate: .default), defaults: .init()) + ] +} + +struct TestParseFiles: Sendable { + + let expectedFiles: [String] + let configuration: Configuration + let defaults: Configuration.Generate + let runOptions: PandocClient.RunOptions? + + init( + expectedFiles: [String], + configuration: Configuration = .init(), + defaults: Configuration.Generate = .default, + runOptions: PandocClient.RunOptions? = nil + ) { + self.expectedFiles = expectedFiles + self.configuration = configuration + self.defaults = defaults + self.runOptions = runOptions + } + + var parsedFiles: [String] { + let runOptions = self.runOptions ?? PandocClient.RunOptions( + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug), + projectDirectory: nil, + quiet: true, + shouldBuild: false + ) + + return runOptions.parseFiles(configuration.generate, defaults) + } + + static let testCases: [Self] = [ + .init(expectedFiles: ["Report.md", "Definitions.md"]), + .init(expectedFiles: ["Report.md", "Definitions.md"], configuration: .init(generate: .default), defaults: .init()), + .init(expectedFiles: [], defaults: .init()), + .init( + expectedFiles: ["custom.md"], + configuration: .init(), + defaults: .init(), + runOptions: .init( + files: ["custom.md"], + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug), + projectDirectory: nil, + quiet: true, + shouldBuild: false + ) + ) + ] +} + +struct TestParseIncludeInHeaderFiles: Sendable { + + let expectedHeaderFiles: [String] + let configuration: Configuration + let defaults: Configuration.Generate + let runOptions: PandocClient.RunOptions? + + init( + expectedHeaderFiles: [String], + configuration: Configuration = .init(), + defaults: Configuration.Generate = .default, + runOptions: PandocClient.RunOptions? = nil + ) { + self.expectedHeaderFiles = expectedHeaderFiles + self.configuration = configuration + self.defaults = defaults + self.runOptions = runOptions + } + + var parsedFiles: [String] { + let runOptions = self.runOptions ?? PandocClient.RunOptions( + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) + ) + + return runOptions.parseIncludeInHeader(configuration.generate, defaults) + } + + static let testCases: [Self] = [ + .init(expectedHeaderFiles: ["head.tex", "footer.tex"]), + .init(expectedHeaderFiles: ["head.tex", "footer.tex"], configuration: .init(generate: .default), defaults: .init()), + .init(expectedHeaderFiles: [], defaults: .init()), + .init( + expectedHeaderFiles: ["custom.tex"], + configuration: .init(), + defaults: .init(), + runOptions: .init( + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug), + includeInHeader: ["custom.tex"] + ) + ) + ] +} + +struct TestParseOutputFileName: Sendable { + + let expected: String + let configuration: Configuration + let defaults: Configuration.Generate + let runOptions: PandocClient.RunOptions? + + init( + expected: String, + configuration: Configuration = .init(), + defaults: Configuration.Generate = .default, + runOptions: PandocClient.RunOptions? = nil + ) { + self.expected = expected + self.configuration = configuration + self.defaults = defaults + self.runOptions = runOptions + } + + var parsedFileName: String { + let runOptions = self.runOptions ?? PandocClient.RunOptions( + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) + ) + + return runOptions.parseOutputFileName(configuration.generate, defaults) + } + + static let testCases: [Self] = [ + .init(expected: "Report"), + .init(expected: "Report", configuration: .init(generate: .default), defaults: .init()), + .init(expected: "Report", defaults: .init()), + .init( + expected: "custom", + configuration: .init(), + defaults: .init(), + runOptions: .init( + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug), + outputFileName: "custom" + ) + ) + ] +} + +struct TestParseBuildDirectory: Sendable { + + let expected: String + let configuration: Configuration + let defaults: Configuration.Generate + let runOptions: PandocClient.RunOptions? + + init( + expected: String = ".build", + configuration: Configuration = .init(), + defaults: Configuration.Generate = .default, + runOptions: PandocClient.RunOptions? = nil + ) { + self.expected = expected + self.configuration = configuration + self.defaults = defaults + self.runOptions = runOptions + } + + var parsedBuildDirectory: String { + let runOptions = self.runOptions ?? PandocClient.RunOptions( + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) + ) + + return runOptions.parseBuildDirectory(configuration.generate, defaults) + } + + static let testCases: [Self] = [ + .init(), + .init(configuration: .init(generate: .default), defaults: .init()), + .init(defaults: .init()), + .init( + expected: "custom", + configuration: .init(), + defaults: .init(), + runOptions: .init( + buildDirectory: "custom", + loggingOptions: .init(commandName: "parseFiles", logLevel: .debug) + ) + ) + ] +} diff --git a/Tests/PlaybookClientTests/PlaybookClientTests.swift b/Tests/PlaybookClientTests/PlaybookClientTests.swift new file mode 100644 index 0000000..ae55130 --- /dev/null +++ b/Tests/PlaybookClientTests/PlaybookClientTests.swift @@ -0,0 +1,308 @@ +import CodersClient +@_spi(Internal) import CommandClient +@_spi(Internal) import ConfigurationClient +import Dependencies +import FileClient +import Foundation +@_spi(Internal) import PlaybookClient +import ShellClient +import Testing +import TestSupport + +@Suite("PlaybookClientTests") +struct PlaybookClientTests: TestCase { + + static var sharedRunOptions: PlaybookClient.RunPlaybook.SharedRunOptions { + .init(loggingOptions: loggingOptions) + } + + static let defaultPlaybookPath = "~/.local/share/hpa/playbook/main.yml" + static let defaultInventoryPath = "~/.local/share/hpa/playbook/inventory.ini" + static let mockVaultArg = Configuration.mock.vault.args![0] + + @Test(.tags(.repository)) + func repositoryInstallation() async throws { + try await withTestLogger(key: "repositoryInstallation") { + $0.fileClient = .liveValue + $0.asyncShellClient = .liveValue + $0.commandClient = .liveValue + } operation: { + try await withTemporaryDirectory { tempDirectory in + @Dependency(\.fileClient) var fileClient + @Dependency(\.logger) var logger + let pathUrl = tempDirectory.appending(path: "playbook") + let playbookClient = PlaybookClient.liveValue + + let configuration = Configuration(playbook: .init(directory: pathUrl.cleanFilePath)) + + try? FileManager.default.removeItem(at: pathUrl) + try await playbookClient.repository.install(configuration) + logger.debug("Done cloning playbook") + let exists = try await fileClient.isDirectory(pathUrl) + #expect(exists) + } + } + } + + @Test( + .tags(.repository), + arguments: [ + (Configuration(), PlaybookClient.Constants.defaultInstallationPath), + (Configuration(playbook: .init(directory: "playbook")), "playbook") + ] + ) + func repositoryDirectory(configuration: Configuration, expected: String) async throws { + let client = PlaybookClient.liveValue + let result = try await client.repository.directory(configuration) + #expect(result == expected) + } + + @Test(.tags(.run)) + func runBuildProject() async throws { + let captured = CommandClient.CapturingClient() + + try await withMockConfiguration(captured, key: "runBuildProject") { + @Dependency(\.playbookClient) var playbookClient + + try await playbookClient.run.buildProject(.init(projectDirectory: "/foo", shared: Self.sharedRunOptions)) + + let arguments = await captured.options!.arguments + print(arguments) + + #expect(arguments == [ + "ansible-playbook", Self.defaultPlaybookPath, + "--inventory", Self.defaultInventoryPath, + Self.mockVaultArg, + "--tags", "build-project", + "--extra-vars", "project_dir=/foo" + ]) + } + } + + @Test( + .tags(.run), + arguments: [ + (true, "\'{\"template\":{\"path\":\"\(Configuration.mock.template.directory!)\"}}\'"), + (false, "\'{\"template\":{\"repo\":{\"url\":\"\(Configuration.mock.template.url!)\",\"version\":\"\(Configuration.mock.template.version!)\"}}}\'") + ] + ) + func runCreateProject(useLocalTemplateDirectory: Bool, json: String) async throws { + let captured = CommandClient.CapturingClient() + + try await withMockConfiguration(captured, key: "runBuildProject") { + @Dependency(\.logger) var logger + @Dependency(\.playbookClient) var playbookClient + + try await playbookClient.run.createProject( + .init( + projectDirectory: "/project", + shared: Self.sharedRunOptions, + useLocalTemplateDirectory: useLocalTemplateDirectory + ) + ) + + let arguments = await captured.options!.arguments + logger.debug("\(arguments)") + + #expect(arguments == [ + "ansible-playbook", Self.defaultPlaybookPath, + "--inventory", Self.defaultInventoryPath, + Self.mockVaultArg, + "--tags", "setup-project", + "--extra-vars", "project_dir=/project", + "--extra-vars", json + ]) + } + } + + @Test(arguments: CreateJsonTestOption.testCases) + func createJson(input: CreateJsonTestOption) { + withTestLogger(key: "generateJson") { + $0.coders.jsonEncoder = { jsonEncoder } + $0.configurationClient = .mock(input.configuration) + } operation: { + @Dependency(\.coders) var coders + + let jsonData = try? input.options.createJSONData( + configuration: input.configuration, + encoder: coders.jsonEncoder() + ) + + switch input.expectation { + case let .success(expected): + let json = String(data: jsonData!, encoding: .utf8)! + if json != expected { + print("json:", json) + print("expected:", expected) + } + #expect(json == expected) + case .failure: + #expect(jsonData == nil) + } + } + } + + @Test + func generateTemplate() async throws { + try await withCapturingCommandClient("generateTemplate") { + $0.configurationClient = .mock() + $0.playbookClient = .liveValue + } run: { + @Dependency(\.playbookClient) var playbookClient + + let output = try await playbookClient.run.generateTemplate(.init( + shared: Self.sharedRunOptions, + templateDirectory: "/template" + )) + + #expect(output == "/template") + + } assert: { output in + + let expected = [ + "ansible-playbook", Self.defaultPlaybookPath, + "--inventory", Self.defaultInventoryPath, + Self.mockVaultArg, + "--tags", "repo-template", + "--extra-vars", "output_dir=/template" + ] + + #expect(output.arguments == expected) + } + } + + func withMockConfiguration( + _ capturing: CommandClient.CapturingClient, + configuration: Configuration = .mock, + key: String, + logLevel: Logger.Level = .trace, + depednencies setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in }, + operation: @Sendable @escaping () async throws -> Void + ) async rethrows { + try await withDependencies { + $0.configurationClient = .mock(configuration) + $0.commandClient = .capturing(capturing) + $0.playbookClient = .liveValue + setupDependencies(&$0) + } operation: { + try await operation() + } + } + +} + +struct CreateJsonTestOption: Sendable { + let options: PlaybookClient.RunPlaybook.CreateOptions + let configuration: Configuration + let expectation: Result + + static let testCases: [Self] = [ + CreateJsonTestOption( + options: .init( + projectDirectory: "/project", + shared: PlaybookClientTests.sharedRunOptions, + template: .init(url: nil, version: nil, directory: nil), + useLocalTemplateDirectory: true + ), + configuration: .init(), + expectation: .failing + ), + CreateJsonTestOption( + options: .init( + projectDirectory: "/project", + shared: PlaybookClientTests.sharedRunOptions, + template: .init(url: nil, version: nil, directory: nil), + useLocalTemplateDirectory: false + ), + configuration: .init(), + expectation: .failing + ), + CreateJsonTestOption( + options: .init( + projectDirectory: "/project", + shared: PlaybookClientTests.sharedRunOptions, + template: .init(url: nil, version: nil, directory: "/template"), + useLocalTemplateDirectory: true + ), + configuration: .init(template: .init(directory: "/template")), + expectation: .success(""" + { + "template" : { + "path" : "/template" + } + } + """) + ), + CreateJsonTestOption( + options: .init( + projectDirectory: "/project", + shared: PlaybookClientTests.sharedRunOptions, + template: .init(url: nil, version: nil, directory: "/template"), + useLocalTemplateDirectory: true + ), + configuration: .init(template: .init(directory: "/template")), + expectation: .success(""" + { + "template" : { + "path" : "/template" + } + } + """) + ), + CreateJsonTestOption( + options: .init( + projectDirectory: "/project", + shared: PlaybookClientTests.sharedRunOptions, + template: .init(url: "https://git.example.com/template.git", version: "main", directory: nil), + useLocalTemplateDirectory: false + ), + configuration: .init(), + expectation: .success(""" + { + "template" : { + "repo" : { + "url" : "https://git.example.com/template.git", + "version" : "main" + } + } + } + """) + ), + CreateJsonTestOption( + options: .init( + projectDirectory: "/project", + shared: PlaybookClientTests.sharedRunOptions, + template: .init(url: "https://git.example.com/template.git", version: "v0.1.0", directory: nil), + useLocalTemplateDirectory: false + ), + configuration: .init(template: .init(url: "https://git.example.com/template.git", version: "v0.1.0")), + expectation: .success(""" + { + "template" : { + "repo" : { + "url" : "https://git.example.com/template.git", + "version" : "v0.1.0" + } + } + } + """) + ) + ] +} + +extension Result where Failure == TestError { + static var failing: Self { .failure(TestError()) } +} + +struct TestError: Error {} + +extension Tag { + @Tag static var repository: Self + @Tag static var run: Self +} + +let jsonEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys] + return encoder +}() diff --git a/Tests/VaultClientTests/VaultClientTests.swift b/Tests/VaultClientTests/VaultClientTests.swift new file mode 100644 index 0000000..8ecbd4f --- /dev/null +++ b/Tests/VaultClientTests/VaultClientTests.swift @@ -0,0 +1,129 @@ +@_spi(Internal) import ConfigurationClient +import FileClient +import Foundation +import Testing +import TestSupport +@_spi(Internal) import VaultClient + +@Suite("VaultClientTests") +struct VaultClientTests: TestCase { + + @Test( + arguments: TestOptions.testCases + ) + func decrypt(input: TestOptions) async throws { + try await withCapturingCommandClient("decrypt") { + $0.configurationClient = .mock(input.configuration) + $0.fileClient.findVaultFile = { _ in URL(filePath: "/vault.yml") } + $0.vaultClient = .liveValue + } run: { + @Dependency(\.vaultClient) var vaultClient + + let output = try await vaultClient.run.decrypt(.init( + extraOptions: input.extraOptions, + loggingOptions: Self.loggingOptions, + outputFilePath: input.outputFilePath, + vaultFilePath: input.vaultFilePath + )) + + if let outputFilePath = input.outputFilePath { + #expect(output == outputFilePath) + } else if let vaultFilePath = input.vaultFilePath { + #expect(output == vaultFilePath) + } else { + #expect(output == "/vault.yml") + } + } assert: { options in + + #expect(options.arguments == input.expected(.decrypt)) + } + } + + @Test( + arguments: TestOptions.testCases + ) + func encrypt(input: TestOptions) async throws { + try await withCapturingCommandClient("decrypt") { + $0.configurationClient = .mock(input.configuration) + $0.fileClient.findVaultFile = { _ in URL(filePath: "/vault.yml") } + $0.vaultClient = .liveValue + } run: { + @Dependency(\.vaultClient) var vaultClient + + let output = try await vaultClient.run.encrypt(.init( + extraOptions: input.extraOptions, + loggingOptions: Self.loggingOptions, + outputFilePath: input.outputFilePath, + vaultFilePath: input.vaultFilePath + )) + + if let outputFilePath = input.outputFilePath { + #expect(output == outputFilePath) + } else if let vaultFilePath = input.vaultFilePath { + #expect(output == vaultFilePath) + } else { + #expect(output == "/vault.yml") + } + + } assert: { options in + #expect(options.arguments == input.expected(.encrypt)) + } + } + +} + +struct TestOptions: Sendable { + let configuration: Configuration + let extraOptions: [String]? + let outputFilePath: String? + let vaultFilePath: String? + + init( + configuration: Configuration = .init(), + extraOptions: [String]? = nil, + outputFilePath: String? = nil, + vaultFilePath: String? = nil + ) { + self.configuration = configuration + self.extraOptions = extraOptions + self.outputFilePath = outputFilePath + self.vaultFilePath = vaultFilePath + } + + func expected(_ route: VaultClient.Route) -> [String] { + var expected = [ + "ansible-vault", "\(route.verb)" + ] + + if let outputFilePath { + expected.append(contentsOf: ["--output", outputFilePath]) + } + + if let extraOptions { + expected.append(contentsOf: extraOptions) + } + + if let vaultArgs = configuration.vault.args { + expected.append(contentsOf: vaultArgs) + } + + if route == .encrypt, + let id = configuration.vault.encryptId + { + expected.append(contentsOf: ["--encrypt-vault-id", id]) + } + + expected.append(vaultFilePath ?? "/vault.yml") + + return expected + } + + static let testCases: [Self] = [ + TestOptions(vaultFilePath: "/vault.yml"), + TestOptions(extraOptions: ["--verbose"]), + TestOptions(configuration: .mock), + TestOptions(outputFilePath: "/output.yml") + ] +} + +struct TestError: Error {} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100755 index 0000000..4131239 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,23 @@ +# Used this to build the release version of the image. +# Build the executable +ARG SWIFT_IMAGE_VERSION="6.0.3" + +FROM swift:${SWIFT_IMAGE_VERSION} AS build +WORKDIR /build +COPY ./Package.* ./ +RUN swift package resolve +COPY . . +RUN swift build -c release -Xswiftc -g + +# Run image +FROM swift:${SWIFT_IMAGE_VERSION}-slim + +RUN export DEBIAN_FRONTEND=nointeractive DEBCONF_NOINTERACTIVE_SEEN=true && apt-get -q update && \ + apt-get -q install -y \ + ansible \ + pandoc \ + texlive \ + && rm -r /var/lib/apt/lists/* + +COPY --from=build /build/.build/release/hpa /usr/local/bin +CMD ["/bin/bash", "-xc", "/usr/local/bin/hpa"] diff --git a/docker/Dockerfile.test b/docker/Dockerfile.test new file mode 100644 index 0000000..5724c82 --- /dev/null +++ b/docker/Dockerfile.test @@ -0,0 +1,10 @@ +# Used to build a test image. +ARG SWIFT_IMAGE_VERSION="6.0.3" +FROM swift:${SWIFT_IMAGE_VERSION} + +WORKDIR /app +COPY ./Package.* ./ +RUN swift package resolve +COPY . . +RUN swift build +CMD ["/bin/bash", "-xc", "swift", "test"] diff --git a/justfile b/justfile index 051c6b3..c7e7e86 100644 --- a/justfile +++ b/justfile @@ -1,15 +1,45 @@ +docker_image_name := "swift-hpa" +install_path := "~/.local/share/bin/hpa" +completion_path := "~/.local/share/zsh/completions/_hpa" build mode="debug": swift build -c {{mode}} alias b := build +build-docker file="Dockerfile" tag="latest": + @docker build \ + --file docker/{{file}} \ + --tag {{docker_image_name}}:{{tag}} . + +build-docker-test: (build-docker "Dockerfile.test" "test") + test *ARGS: swift test {{ARGS}} alias t := test +test-docker *ARGS: (build-docker-test) + @docker run --rm \ + --network host \ + {{docker_image_name}}:test \ + swift test {{ARGS}} + run *ARGS: swift run hpa {{ARGS}} alias r := run + +clean: + rm -rf .build + +update-version: + @swift package \ + --disable-sandbox \ + --allow-writing-to-package-directory \ + update-version \ + hpa + +install: (build "release") + @cp .build/release/hpa {{install_path}} + @{{install_path}} --generate-completion-script zsh > {{completion_path}}