feat: Adds generate commands that call to pandoc to generate pdf, latex, and html files from a project.

This commit is contained in:
2024-12-13 15:33:20 -05:00
parent d1b3379815
commit 3f56dda568
22 changed files with 606 additions and 57 deletions

View File

@@ -32,9 +32,6 @@ let package = Package(
.product(name: "CliDoc", package: "swift-cli-doc"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "ShellClient", package: "swift-shell-client")
],
plugins: [
.plugin(name: "BuildWithVersionPlugin", package: "swift-cli-version")
]
),
.target(
@@ -109,7 +106,10 @@ let package = Package(
),
.testTarget(
name: "PlaybookClientTests",
dependencies: ["PlaybookClient"]
dependencies: [
"PlaybookClient",
"TestSupport"
]
)
]
)

View File

@@ -0,0 +1,223 @@
import ConfigurationClient
import Dependencies
import Foundation
import ShellClient
// TODO: Need to parse / ensure that header includes and files are in the build directory, not sure
// exactly how to handle if they're not, but it seems reasonable to potentially allow files that are
// outside of the build directory to be included.
public extension CliClient {
@discardableResult
func runPandocCommand(
_ options: PandocOptions,
logging loggingOptions: LoggingOptions
) async throws -> String {
try await withLogger(loggingOptions) {
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.logger) var logger
let configuration = try await configurationClient.findAndLoad()
logger.trace("Configuration: \(configuration)")
let ensuredOptions = try await ensurePandocOptions(
configuration: configuration,
options: options
)
let projectDirectory = options.projectDirectory ?? ProcessInfo.processInfo.environment["PWD"]
guard let projectDirectory else {
throw CliClientError.generate(.projectDirectoryNotSpecified)
}
let outputDirectory = options.outputDirectory ?? projectDirectory
let outputPath = "\(outputDirectory)/\(ensuredOptions.ensuredExtensionFileName)"
var arguments = [
"pandoc"
]
arguments += ensuredOptions.includeInHeader.map {
"--include-in-header=\(projectDirectory)/\(ensuredOptions.buildDirectory)/\($0)"
}
if let pdfEngine = ensuredOptions.pdfEngine {
arguments.append("--pdf-engine=\(pdfEngine)")
}
arguments.append("--output=\(outputPath)")
arguments += ensuredOptions.files.map {
"\(projectDirectory)/\(ensuredOptions.buildDirectory)/\($0)"
}
if options.shouldBuild {
logger.trace("Building project...")
try await runPlaybookCommand(
.init(
arguments: [
"--tags", "build-project",
"--extra-vars", "project_dir=\(projectDirectory)"
],
configuration: configuration,
quiet: options.quiet,
shell: options.shell
),
logging: loggingOptions
)
}
logger.trace("Running pandoc with arguments: \(arguments)")
try await runCommand(
quiet: options.quiet,
shell: options.shell.orDefault,
arguments
)
return outputPath
}
}
}
@_spi(Internal)
public struct EnsuredPandocOptions: Equatable, Sendable {
public let buildDirectory: String
public let files: [String]
public let includeInHeader: [String]
public let outputFileName: String
public let outputFileType: CliClient.PandocOptions.FileType
public let pdfEngine: String?
public var ensuredExtensionFileName: String {
let extensionString: String
switch outputFileType {
case .html:
extensionString = ".html"
case .latex:
extensionString = ".tex"
case .pdf:
extensionString = ".pdf"
}
if !outputFileName.hasSuffix(extensionString) {
return outputFileName + extensionString
}
return outputFileName
}
}
@_spi(Internal)
public func ensurePandocOptions(
configuration: Configuration,
options: CliClient.PandocOptions
) async throws -> EnsuredPandocOptions {
let defaults = Configuration.Generate.default
let pdfEngine = parsePdfEngine(configuration.generate, defaults, options)
return .init(
buildDirectory: parseBuildDirectory(configuration.generate, defaults, options),
files: parseFiles(configuration.generate, defaults, options),
includeInHeader: parseIncludeInHeader(configuration.generate, defaults, options),
outputFileName: parseOutputFileName(configuration.generate, defaults, options),
outputFileType: options.outputFileType,
pdfEngine: pdfEngine
)
}
private func parsePdfEngine(
_ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate,
_ options: CliClient.PandocOptions
) -> String? {
switch options.outputFileType {
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 "xelatex"
}
}
}
private func parseFiles(
_ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate,
_ options: CliClient.PandocOptions
) -> [String] {
@Dependency(\.logger) var logger
if let files = options.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 []
}
}
private func parseIncludeInHeader(
_ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate,
_ options: CliClient.PandocOptions
) -> [String] {
@Dependency(\.logger) var logger
if let files = options.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 []
}
}
private func parseOutputFileName(
_ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate,
_ options: CliClient.PandocOptions
) -> String {
@Dependency(\.logger) var logger
if let output = options.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 "Report"
}
}
private func parseBuildDirectory(
_ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate,
_ options: CliClient.PandocOptions
) -> String {
@Dependency(\.logger) var logger
if let output = options.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"
}
}

View File

@@ -4,7 +4,12 @@ public enum CliClientError: Error {
case brewfileNotFound
case encodingError
case playbookDirectoryNotFound
case generate(GenerateError)
case templateDirectoryNotFound
case templateDirectoryOrRepoNotSpecified
case vaultFileNotFound
public enum GenerateError: Sendable {
case projectDirectoryNotSpecified
}
}

View File

@@ -27,6 +27,52 @@ public struct CliClient: Sendable {
public extension CliClient {
struct PandocOptions: Equatable, Sendable {
let buildDirectory: String?
let files: [String]?
let includeInHeader: [String]?
let outputDirectory: String?
let outputFileName: String?
let outputFileType: FileType
let projectDirectory: String?
let quiet: Bool
let shell: String?
let shouldBuild: Bool
public init(
buildDirectory: String? = nil,
files: [String]? = nil,
includeInHeader: [String]? = nil,
outputDirectory: String? = nil,
outputFileName: String? = nil,
outputFileType: FileType,
projectDirectory: String?,
quiet: Bool,
shell: String? = nil,
shouldBuild: Bool
) {
self.buildDirectory = buildDirectory
self.files = files
self.includeInHeader = includeInHeader
self.outputDirectory = outputDirectory
self.outputFileName = outputFileName
self.outputFileType = outputFileType
self.projectDirectory = projectDirectory
self.quiet = quiet
self.shell = shell
self.shouldBuild = shouldBuild
}
// swiftlint:disable nesting
public enum FileType: Equatable, Sendable {
case html
case latex
case pdf(engine: String?)
}
// swiftlint:enable nesting
}
struct GenerateJsonOptions: Equatable, Sendable {
let templateDirectory: String?
let templateRepo: String?

View File

@@ -1,9 +1,12 @@
import Foundation
// NOTE: When adding items, then the 'hpa.toml' resource file needs to be updated.
/// Represents configurable settings for the command line tool.
public struct Configuration: Codable, Equatable, Sendable {
public let args: [String]?
public let useVaultArgs: Bool
public let generate: Generate?
public let playbook: Playbook?
public let template: Template
public let vault: Vault
@@ -11,12 +14,14 @@ public struct Configuration: Codable, Equatable, Sendable {
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
@@ -32,6 +37,40 @@ public struct Configuration: Codable, Equatable, Sendable {
)
}
public struct Generate: Codable, Equatable, Sendable {
public let buildDirectory: String?
public let files: [String]?
public let includeInHeader: [String]?
public let outputFileName: String?
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
}
public static let `default` = Self.mock
public static var mock: Self {
.init(
buildDirectory: ".build",
files: ["Report.md", "Definitions.md"],
includeInHeader: ["head.tex", "footer.tex"],
outputFileName: "Report",
pdfEngine: "xelatex"
)
}
}
public struct Playbook: Codable, Equatable, Sendable {
public let directory: String?

View File

@@ -1,13 +1,42 @@
# NOTE:
# Configuration settings for the `hpa` command line tool.
#
# Delete settings that are not applicable to your use case.
# 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.
@@ -17,6 +46,7 @@ useVaultArgs = true
#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]
@@ -31,6 +61,7 @@ url = 'https://git.example.com/consult-template.git'
# 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

View File

@@ -39,14 +39,13 @@ private func install(config: Configuration.Playbook?) async throws {
@Dependency(\.logger) var logger
@Dependency(\.asyncShellClient) var shell
let path = config?.directory ?? PlaybookClient.Constants.defaultInstallationPath
let version = config?.version ?? PlaybookClient.Constants.playbookRepoVersion
let (path, version) = parsePlaybookPathAndVerion(config)
let parentDirectory = URL(filePath: path)
.deletingLastPathComponent()
let exists = try await fileClient.isDirectory(parentDirectory)
if !exists {
let parentExists = try await fileClient.isDirectory(parentDirectory)
if !parentExists {
try await fileClient.createDirectory(parentDirectory)
}
@@ -70,3 +69,10 @@ private func install(config: Configuration.Playbook?) async throws {
))
}
}
private func parsePlaybookPathAndVerion(_ configuration: Configuration.Playbook?) -> (path: String, version: String) {
return (
path: configuration?.directory ?? PlaybookClient.Constants.defaultInstallationPath,
version: configuration?.version ?? PlaybookClient.Constants.playbookRepoVersion
)
}

View File

@@ -6,10 +6,6 @@ import Logging
public protocol TestCase {}
public extension TestCase {
// static var logLevel: Logger.Level = {
// let levelString = ProcessInfo.processInfo.environment["LOG_LEVEL"] ?? "debug"
// return Logger.Level(rawValue: levelString) ?? .debug
// }()
func withTestLogger(
key: String,

View File

@@ -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

View File

@@ -9,9 +9,13 @@ struct Application: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: Constants.appName,
abstract: createAbstract("A utility for working with ansible hpa playbook."),
version: VERSION,
version: VERSION ?? "0.0.0",
subcommands: [
BuildCommand.self, CreateCommand.self, VaultCommand.self, UtilsCommand.self
BuildCommand.self,
CreateCommand.self,
GenerateCommand.self,
VaultCommand.self,
UtilsCommand.self
]
)

View File

@@ -1,6 +1,7 @@
import ArgumentParser
import CliClient
import Dependencies
import Foundation
struct BuildCommand: AsyncParsableCommand {
@@ -9,16 +10,23 @@ 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 / options passed to the playbook."
@@ -27,19 +35,21 @@ struct BuildCommand: AsyncParsableCommand {
mutating func run() async throws {
try await _run()
// try await runPlaybook(
// commandName: Self.commandName,
// globals: globals,
// extraArgs: extraArgs,
// "--tags", "build-project",
// "--extra-vars", "project_dir=\(projectDir)"
// )
}
private func _run() async throws {
@Dependency(\.cliClient) var cliClient
let projectDir: String
if projectDirectory == nil {
guard let pwd = ProcessInfo.processInfo.environment["PWD"] else {
throw ProjectDirectoryNotSupplied()
}
projectDir = pwd
} else {
projectDir = projectDirectory!
}
try await cliClient.runPlaybookCommand(
globals.playbookOptions(
arguments: [
@@ -52,3 +62,5 @@ struct BuildCommand: AsyncParsableCommand {
)
}
}
struct ProjectDirectoryNotSupplied: Error {}

View File

@@ -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
]
)
}

View File

@@ -0,0 +1,24 @@
import ArgumentParser
import CliClient
import Dependencies
// 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(\.cliClient) var cliClient
try await cliClient.runPandocCommand(
globals.pandocOptions(.html),
logging: globals.loggingOptions(commandName: Self.commandName)
)
}
}

View File

@@ -0,0 +1,23 @@
import ArgumentParser
import CliClient
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(\.cliClient) var cliClient
try await cliClient.runPandocCommand(
globals.pandocOptions(.latex),
logging: globals.loggingOptions(commandName: Self.commandName)
)
}
}

View File

@@ -0,0 +1,87 @@
import ArgumentParser
import CliClient
@dynamicMemberLookup
struct GenerateOptions: ParsableArguments {
@OptionGroup var basic: BasicGlobalOptions
@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<T>(dynamicMember keyPath: WritableKeyPath<BasicGlobalOptions, T>) -> T {
get { basic[keyPath: keyPath] }
set { basic[keyPath: keyPath] = newValue }
}
subscript<T>(dynamicMember keyPath: KeyPath<BasicGlobalOptions, T>) -> T {
basic[keyPath: keyPath]
}
}
extension GenerateOptions {
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
basic.loggingOptions(commandName: commandName)
}
func pandocOptions(
_ fileType: CliClient.PandocOptions.FileType
) -> CliClient.PandocOptions {
.init(
files: files.count > 0 ? files : nil,
includeInHeader: includeInHeader.count > 0 ? includeInHeader : nil,
outputDirectory: outputDirectory,
outputFileName: outputFileName,
outputFileType: fileType,
projectDirectory: projectDirectory,
quiet: basic.quiet,
shell: basic.shell,
shouldBuild: !noBuild
)
}
}

View File

@@ -0,0 +1,32 @@
import ArgumentParser
import CliClient
import Dependencies
// 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(\.cliClient) var cliClient
let output = try await cliClient.runPandocCommand(
globals.pandocOptions(.pdf(engine: pdfEngine)),
logging: globals.loggingOptions(commandName: Self.commandName)
)
print(output)
}
}

View File

@@ -27,10 +27,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,

View File

@@ -11,7 +11,7 @@ extension GlobalOptions {
arguments: arguments,
configuration: configuration,
inventoryFilePath: inventoryPath,
playbookDirectory: playbookDir,
playbookDirectory: playbookDirectory,
quiet: quietOnlyPlaybook ? true : basic.quiet,
shell: basic.shell
)

View File

@@ -0,0 +1,2 @@
// Do not set this variable, it is set during the build process.
let VERSION: String? = nil

View File

@@ -143,23 +143,6 @@ func generateFindEnvironments(file: File) -> [[String: String]] {
]
}
// swiftlint:disable force_try
func withTemporaryDirectory(
_ operation: @Sendable (URL) async throws -> Void
) async rethrows {
let dir = FileManager.default.temporaryDirectory
let tempDir = dir.appending(path: UUID().uuidString)
try! FileManager.default.createDirectory(
atPath: tempDir.cleanFilePath,
withIntermediateDirectories: false
)
try await operation(tempDir)
try! FileManager.default.removeItem(at: tempDir)
}
// swiftlint:enable force_try
func withGeneratedConfigFile(
named fileName: String,
client: ConfigurationClient,

View File

@@ -5,6 +5,7 @@ import Foundation
@_spi(Internal) import PlaybookClient
import ShellClient
import Testing
import TestSupport
@Suite("PlaybookClientTests")
struct PlaybookClientTests {
@@ -15,7 +16,7 @@ struct PlaybookClientTests {
$0.fileClient = .liveValue
$0.asyncShellClient = .liveValue
} operation: {
let tempDirectory = FileManager.default.temporaryDirectory
try await withTemporaryDirectory { tempDirectory in
let pathUrl = tempDirectory.appending(path: "playbook")
let playbookClient = PlaybookClient.liveValue
@@ -25,8 +26,7 @@ struct PlaybookClientTests {
try await playbookClient.installPlaybook(configuration)
let exists = FileManager.default.fileExists(atPath: pathUrl.cleanFilePath)
#expect(exists)
try FileManager.default.removeItem(at: pathUrl)
}
}
}

View File

@@ -16,3 +16,10 @@ alias r := run
clean:
rm -rf .build
update-version:
@swift package \
--disable-sandbox \
--allow-writing-to-package-directory \
update-version \
hpa