From f8e89ed0faeb20aba93625b4e531d173600ebc6f Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Tue, 17 Dec 2024 11:51:03 -0500 Subject: [PATCH] feat: Adding documentation comments. --- Sources/CommandClient/CommandClient.swift | 18 +++++ .../ConfigurationClient/Configuration.swift | 71 +++++++++++++++++++ .../ConfigurationClient.swift | 58 +++++++++++---- Sources/ConfigurationClient/Constants.swift | 3 + Sources/ConfigurationClient/File.swift | 29 +++++--- .../ConfigurationClientTests.swift | 21 ++++++ 6 files changed, 179 insertions(+), 21 deletions(-) diff --git a/Sources/CommandClient/CommandClient.swift b/Sources/CommandClient/CommandClient.swift index d730b9a..f283ff8 100644 --- a/Sources/CommandClient/CommandClient.swift +++ b/Sources/CommandClient/CommandClient.swift @@ -110,15 +110,33 @@ public struct CommandClient: Sendable { } } +/// 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 diff --git a/Sources/ConfigurationClient/Configuration.swift b/Sources/ConfigurationClient/Configuration.swift index 8fe731d..385f4fa 100644 --- a/Sources/ConfigurationClient/Configuration.swift +++ b/Sources/ConfigurationClient/Configuration.swift @@ -52,11 +52,34 @@ public struct Configuration: Codable, Equatable, Sendable { ) } + /// 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( @@ -73,8 +96,10 @@ public struct Configuration: Codable, Equatable, Sendable { 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", @@ -86,10 +111,22 @@ public struct Configuration: Codable, Equatable, Sendable { } } + /// 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( @@ -105,9 +142,28 @@ public struct Configuration: Codable, Equatable, Sendable { 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( @@ -129,8 +185,23 @@ public struct Configuration: Codable, Equatable, Sendable { } } + /// 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( diff --git a/Sources/ConfigurationClient/ConfigurationClient.swift b/Sources/ConfigurationClient/ConfigurationClient.swift index b767805..31e2878 100644 --- a/Sources/ConfigurationClient/ConfigurationClient.swift +++ b/Sources/ConfigurationClient/ConfigurationClient.swift @@ -6,24 +6,57 @@ 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. + /// var write: @Sendable (File, Configuration, Bool) 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, @@ -60,15 +93,12 @@ extension ConfigurationClient: DependencyKey { public static func live(environment: [String: String]) -> Self { let liveClient = LiveConfigurationClient(environment: environment) - return .init { - try await liveClient.find() - } generate: { - try await liveClient.generate($0) - } load: { file in - try await liveClient.load(file: file) - } write: { file, configuration, force in - try await liveClient.write(configuration, to: file, force: force) - } + 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($1, to: $0, force: $2) } + ) } public static var liveValue: Self { @@ -221,10 +251,12 @@ struct LiveConfigurationClient { to file: File, force: Bool ) async throws { - if !force { - guard !fileManager.fileExists(file.url) else { - throw ConfigurationError.fileExists(path: file.path) - } + let exists = fileManager.fileExists(file.url) + + if !force, exists { + let backupUrl = file.url.appendingPathExtension(".back") + logger.warning("File exists, creating a backup of the existing file at: \(backupUrl.cleanFilePath)") + try await fileManager.copy(file.url, backupUrl) } let data: Data diff --git a/Sources/ConfigurationClient/Constants.swift b/Sources/ConfigurationClient/Constants.swift index b4c40f2..689794a 100644 --- a/Sources/ConfigurationClient/Constants.swift +++ b/Sources/ConfigurationClient/Constants.swift @@ -1,3 +1,5 @@ +/// 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" @@ -5,6 +7,7 @@ public enum EnvironmentKey { 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" diff --git a/Sources/ConfigurationClient/File.swift b/Sources/ConfigurationClient/File.swift index 821e044..03ee3f3 100644 --- a/Sources/ConfigurationClient/File.swift +++ b/Sources/ConfigurationClient/File.swift @@ -2,11 +2,21 @@ 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) @@ -17,18 +27,17 @@ public enum File: Equatable, Sendable { } } + /// 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)) } - public static func json(_ path: String) -> Self { - .json(URL(filePath: path)) - } - - public static func toml(_ path: String) -> Self { - .toml(URL(filePath: path)) - } - + /// Get the url of the file. public var url: URL { switch self { case let .json(url): return url @@ -36,10 +45,14 @@ public enum File: Equatable, Sendable { } } + /// 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 diff --git a/Tests/ConfigurationClientTests/ConfigurationClientTests.swift b/Tests/ConfigurationClientTests/ConfigurationClientTests.swift index 98ee356..a23488b 100644 --- a/Tests/ConfigurationClientTests/ConfigurationClientTests.swift +++ b/Tests/ConfigurationClientTests/ConfigurationClientTests.swift @@ -141,6 +141,27 @@ struct ConfigurationClientTests: TestCase { } } } + + @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]] {