From b5afc77428b6e04e97010c6bfda458cbb61426ac Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Fri, 13 Dec 2024 23:00:40 -0500 Subject: [PATCH] feat: Beginning to refactor cli-client --- .swiftlint.yml | 1 + Package.swift | 17 ++ Sources/CliClient/CliClient+Commands.swift | 25 -- Sources/CliClient/CliClient+RunPlaybook.swift | 216 ++++++++++++++++++ Sources/CliClient/CliClientError.swift | 1 + Sources/CliClient/GenerateJson.swift | 1 + Sources/CliClient/Interface.swift | 98 ++++++++ Sources/CliClient/LoggingExtensions.swift | 1 + Sources/CommandClient/CommandClient.swift | 113 +++++++++ Sources/Constants/Constants.swift | 3 + .../LoggingExtensions/LoggingExtensions.swift | 27 +++ Sources/PlaybookClient/PlaybookClient.swift | 1 + 12 files changed, 479 insertions(+), 25 deletions(-) create mode 100644 Sources/CliClient/CliClient+RunPlaybook.swift create mode 100644 Sources/CommandClient/CommandClient.swift create mode 100644 Sources/Constants/Constants.swift create mode 100644 Sources/LoggingExtensions/LoggingExtensions.swift 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/Package.swift b/Package.swift index 747acd2..edba5ed 100644 --- a/Package.swift +++ b/Package.swift @@ -9,6 +9,7 @@ let package = Package( .executable(name: "hpa", targets: ["hpa"]), .library(name: "CliClient", targets: ["CliClient"]), .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"]) @@ -60,6 +61,15 @@ let package = Package( .product(name: "TOMLKit", package: "TOMLKit") ] ), + .target( + name: "CommandClient", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + .product(name: "ShellClient", package: "swift-shell-client") + ] + ), + .target(name: "Constants"), .target( name: "TestSupport", dependencies: [ @@ -94,6 +104,13 @@ let package = Package( .product(name: "DependenciesMacros", package: "swift-dependencies") ] ), + .target( + name: "LoggingExtensions", + dependencies: [ + "Constants", + .product(name: "ShellClient", package: "swift-shell-client") + ] + ), .target( name: "PlaybookClient", dependencies: [ diff --git a/Sources/CliClient/CliClient+Commands.swift b/Sources/CliClient/CliClient+Commands.swift index 6544040..ca2e578 100644 --- a/Sources/CliClient/CliClient+Commands.swift +++ b/Sources/CliClient/CliClient+Commands.swift @@ -149,31 +149,6 @@ public extension ConfigurationClient { } } -// @_spi(Internal) -// public extension Configuration { - -// func defaultPlaybookDirectory() throws -> String { -// let playbookDirectory = Bundle.module.url( -// forResource: Constants.playbookBundleDirectoryName, -// withExtension: nil -// ) -// guard let playbookDirectory else { -// throw CliClientError.playbookDirectoryNotFound -// } -// return playbookDirectory.cleanFilePath -// } - -// func ensuredPlaybookDirectory(_ optionalDirectory: String?) throws -> String { -// guard let directory = optionalDirectory else { -// guard let directory = playbook?.directory else { -// return try defaultPlaybookDirectory() -// } -// return directory -// } -// return directory -// } -// } - @_spi(Internal) public extension Optional where Wrapped == String { var orDefault: ShellCommand.Shell { diff --git a/Sources/CliClient/CliClient+RunPlaybook.swift b/Sources/CliClient/CliClient+RunPlaybook.swift new file mode 100644 index 0000000..e604ac8 --- /dev/null +++ b/Sources/CliClient/CliClient+RunPlaybook.swift @@ -0,0 +1,216 @@ +import ConfigurationClient +import Dependencies +import Foundation +import PlaybookClient + +extension CliClient.RunPlaybook { + + static func makeCommonArguments( + configuration: Configuration, + inventoryFilePath: String? + ) async throws -> [String] { + @Dependency(\.logger) var logger + @Dependency(\.playbookClient) var playbookClient + + let playbookDirectory = try await playbookClient.playbookDirectory(configuration) + let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)" + logger.trace("Playbook path: \(playbookPath)") + + let inventoryPath = ensuredInventoryPath( + inventoryFilePath, + configuration: configuration, + playbookDirectory: playbookDirectory + ) + logger.trace("Inventory path: \(inventoryPath)") + + var arguments = [ + 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 + } + +} + +extension CliClient.RunPlaybook.BuildOptions { + private func applyArguments( + to arguments: inout [String], + configuration: Configuration + ) throws { + let projectDirectory = projectDirectory + ?? ProcessInfo.processInfo.environment["PWD"] + + guard let projectDirectory else { + throw CliClientError.projectDirectoryNotFound + } + + arguments.append(contentsOf: [ + "--tags", "build-project", + "--extra-vars", "project_dir=\(projectDirectory)" + ]) + + if let extraOptions = extraOptions { + arguments.append(contentsOf: extraOptions) + } + } +} + +extension CliClient.RunPlaybook.CreateOptions { + private func applyArguments( + to arguments: inout [String], + configuration: Configuration + ) throws { + let json = try createJSONData(configuration: configuration) + + arguments.append(contentsOf: [ + "--tags", "setup-project", + "--extra-vars", "project_dir=\(projectDirectory)", + "--extra-vars", "'\(json)'" + ]) + + if let extraOptions { + arguments.append(contentsOf: extraOptions) + } + } + +} + +extension CliClient.PlaybookOptions.Route { + + private func parseInventoryPath( + _ configuration: Configuration, + _ playbookDirectory: String + ) -> String { + let inventoryFilePath: String? + + switch self { + case let .build(options): + inventoryFilePath = options.inventoryFilePath + case let .create(options): + inventoryFilePath = options.inventoryFilePath + } + + return ensuredInventoryPath( + inventoryFilePath, + configuration: configuration, + playbookDirectory: playbookDirectory + ) + } + + func makeArguments(configuration: Configuration) async throws -> [String] { + @Dependency(\.logger) var logger + @Dependency(\.playbookClient) var playbookClient + + let playbookDirectory = try await playbookClient.playbookDirectory(configuration) + let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)" + logger.trace("Playbook path: \(playbookPath)") + + let inventoryPath = parseInventoryPath(configuration, playbookDirectory) + logger.trace("Inventory path: \(inventoryPath)") + + var arguments = [ + 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) + } + + // try applyArguments(to: &arguments, configuration: configuration) + + 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. + +extension CliClient.RunPlaybook.CreateOptions { + + func createJSONData( + configuration: Configuration, + encoder: JSONEncoder = .init() + ) throws -> Data { + @Dependency(\.logger) var logger + + 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 CliClientError.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 CliClientError.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 + } +} diff --git a/Sources/CliClient/CliClientError.swift b/Sources/CliClient/CliClientError.swift index d61759f..6af8349 100644 --- a/Sources/CliClient/CliClientError.swift +++ b/Sources/CliClient/CliClientError.swift @@ -4,6 +4,7 @@ public enum CliClientError: Error { case brewfileNotFound case encodingError case playbookDirectoryNotFound + case projectDirectoryNotFound case generate(GenerateError) case templateDirectoryNotFound case templateDirectoryOrRepoNotSpecified diff --git a/Sources/CliClient/GenerateJson.swift b/Sources/CliClient/GenerateJson.swift index c7f50db..b9e062c 100644 --- a/Sources/CliClient/GenerateJson.swift +++ b/Sources/CliClient/GenerateJson.swift @@ -6,6 +6,7 @@ import Foundation // want the output to be `prettyPrinted` or anything, unless we're running // tests, so we use a supplied json encoder. +// TODO: Remove. func createJSONData( _ options: CliClient.GenerateJsonOptions, logging loggingOptions: CliClient.LoggingOptions, diff --git a/Sources/CliClient/Interface.swift b/Sources/CliClient/Interface.swift index 49b54ba..b3ebdc6 100644 --- a/Sources/CliClient/Interface.swift +++ b/Sources/CliClient/Interface.swift @@ -25,6 +25,60 @@ public struct CliClient: Sendable { } } +public extension CliClient { + + @DependencyClient + struct RunPlaybook: Sendable { + public var buildProject: @Sendable (RunOptions, BuildOptions) async throws -> Void + public var createProject: @Sendable (RunOptions, CreateOptions) async throws -> String + + public struct RunOptions: Equatable, Sendable { + public let loggingOptions: CliClient.LoggingOptions + public let quiet: Bool + public let shell: String? + } + + public struct BuildOptions: Equatable, Sendable { + public let extraOptions: [String]? + public let inventoryFilePath: String? + public let projectDirectory: String? + + public init( + extraOptions: [String]?, + inventoryFilePath: String?, + projectDirectory: String + ) { + self.extraOptions = extraOptions + self.inventoryFilePath = inventoryFilePath + self.projectDirectory = projectDirectory + } + + } + + public struct CreateOptions: Equatable, Sendable { + public let extraOptions: [String]? + public let inventoryFilePath: String? + public let projectDirectory: String + public let template: Configuration.Template + public let useLocalTemplateDirectory: Bool + + public init( + extraOptions: [String]?, + inventoryFilePath: String?, + projectDirectory: String, + template: Configuration.Template, + useLocalTemplateDirectory: Bool + ) { + self.extraOptions = extraOptions + self.inventoryFilePath = inventoryFilePath + self.projectDirectory = projectDirectory + self.template = template + self.useLocalTemplateDirectory = useLocalTemplateDirectory + } + } + } +} + public extension CliClient { struct PandocOptions: Equatable, Sendable { @@ -125,6 +179,50 @@ public extension CliClient { self.quiet = quiet self.shell = shell } + + public enum Route { + case build(BuildOption) + case create(CreateOption) + + public struct BuildOption: Equatable, Sendable { + public let extraOptions: [String]? + public let inventoryFilePath: String? + public let projectDirectory: String? + + public init( + extraOptions: [String]?, + inventoryFilePath: String?, + projectDirectory: String + ) { + self.extraOptions = extraOptions + self.inventoryFilePath = inventoryFilePath + self.projectDirectory = projectDirectory + } + + } + + public struct CreateOption: Equatable, Sendable { + public let extraOptions: [String]? + public let inventoryFilePath: String? + public let projectDirectory: String + public let template: Configuration.Template + public let useLocalTemplateDirectory: Bool + + public init( + extraOptions: [String]?, + inventoryFilePath: String?, + projectDirectory: String, + template: Configuration.Template, + useLocalTemplateDirectory: Bool + ) { + self.extraOptions = extraOptions + self.inventoryFilePath = inventoryFilePath + self.projectDirectory = projectDirectory + self.template = template + self.useLocalTemplateDirectory = useLocalTemplateDirectory + } + } + } } struct RunCommandOptions: Sendable, Equatable { diff --git a/Sources/CliClient/LoggingExtensions.swift b/Sources/CliClient/LoggingExtensions.swift index 5be5973..39a204c 100644 --- a/Sources/CliClient/LoggingExtensions.swift +++ b/Sources/CliClient/LoggingExtensions.swift @@ -2,6 +2,7 @@ import Dependencies import Logging import ShellClient +// TODO: Remove. public extension CliClient { @discardableResult diff --git a/Sources/CommandClient/CommandClient.swift b/Sources/CommandClient/CommandClient.swift new file mode 100644 index 0000000..4ce91ee --- /dev/null +++ b/Sources/CommandClient/CommandClient.swift @@ -0,0 +1,113 @@ +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. + public func run( + quiet: Bool, + shell: ShellCommand.Shell, + _ arguments: [String] + ) async throws { + try await runCommand(.init(arguments: arguments, quiet: quiet, shell: shell)) + } + + /// Runs a shell command. + public func run( + quiet: Bool, + shell: ShellCommand.Shell, + _ arguments: String... + ) async throws { + try await run(quiet: quiet, shell: shell, arguments) + } + + public struct RunCommandOptions: Sendable, Equatable { + public let arguments: [String] + public let quiet: Bool + public let shell: ShellCommand.Shell + + public init( + arguments: [String], + quiet: Bool, + shell: ShellCommand.Shell + ) { + self.arguments = arguments + self.quiet = quiet + self.shell = shell + } + } +} + +extension CommandClient: DependencyKey { + + public static let testValue: CommandClient = Self() + + public static var liveValue: CommandClient { + .init { options in + @Dependency(\.asyncShellClient) var shellClient + if !options.quiet { + try await shellClient.foreground(.init( + shell: options.shell, + environment: ProcessInfo.processInfo.environment, + in: nil, + options.arguments + )) + } else { + try await shellClient.background(.init( + shell: options.shell, + environment: ProcessInfo.processInfo.environment, + in: nil, + options.arguments + )) + } + } + } +} + +@_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. + /// + actor CapturingClient: Sendable { + public private(set) var quiet: Bool? + public private(set) var shell: ShellCommand.Shell? + public private(set) var arguments: [String]? + + public init() {} + + public func set( + _ options: RunCommandOptions + ) { + quiet = options.quiet + shell = options.shell + arguments = options.arguments + } + } +} + +extension ShellCommand.Shell: @retroactive @unchecked Sendable {} 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/LoggingExtensions/LoggingExtensions.swift b/Sources/LoggingExtensions/LoggingExtensions.swift new file mode 100644 index 0000000..6b6248f --- /dev/null +++ b/Sources/LoggingExtensions/LoggingExtensions.swift @@ -0,0 +1,27 @@ +import Constants +import Dependencies +import Foundation +import ShellClient + +public struct LoggingOptions: Equatable, Sendable { + public let commandName: String + public let logLevel: Logger.Level + + public init(commandName: String, logLevel: Logger.Level) { + self.commandName = commandName + self.logLevel = logLevel + } + + @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() + } + } +} diff --git a/Sources/PlaybookClient/PlaybookClient.swift b/Sources/PlaybookClient/PlaybookClient.swift index d609b7e..f50058e 100644 --- a/Sources/PlaybookClient/PlaybookClient.swift +++ b/Sources/PlaybookClient/PlaybookClient.swift @@ -17,6 +17,7 @@ public extension DependencyValues { @DependencyClient public struct PlaybookClient: Sendable { + // TODO: Remove the configuration and have it passed in. public var installPlaybook: @Sendable (Configuration) async throws -> Void public var playbookDirectory: @Sendable (Configuration) async throws -> String