Template support for input parameters (#4)

This PR contains the work done to support input parameters for the `create` command of the executable target, and to render content dynamically for the newly-generated project.

Reviewed-on: #4
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #4.
This commit is contained in:
2025-02-17 22:11:05 +00:00
committed by Javier Cicchelli
parent 9be8fa4a31
commit 212ca52279
33 changed files with 812 additions and 202 deletions
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2024 Adam Fowler
Copyright 2025 Röck+Cöde
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -11,7 +11,7 @@ struct App: AsyncParsableCommand {
// MARK: Functions
mutating func run() async throws {
let builder = AppBuilder(name: "App")
let builder = AppBuilder(name: "{{ name }}")
let app = try await builder(options)
try await app.runService()
@@ -3,7 +3,7 @@
import PackageDescription
let package = Package(
name: "App",
name: "{{ name }}",
platforms: [
.macOS(.v14)
],
@@ -8,7 +8,7 @@ struct AppTests {
// MARK: Properties
private let arguments = TestArguments()
private let builder = AppBuilder(name: "App")
private let builder = AppBuilder(name: "{{ name }}")
// MARK: Route tests
@@ -1,16 +1,13 @@
enum File: String {
case app = "App"
case appArguments = "AppArguments"
case appBuilder = "AppBuilder"
case appOptions = "AppOptions"
case appTests = "AppTests"
case dockerFile = "DockerFile"
case dockerIgnore = "DockerIgnore"
case environment = "Environment"
case gitIgnore = "GitIgnore"
case license = "License"
case loggerLevel = "LoggerLevel"
case package = "Package"
case readme = "Readme"
case testArguments = "TestArguments"
@@ -24,11 +21,9 @@ extension File {
var fileName: String {
switch self {
case .app: "App.swift"
case .appArguments: "AppArguments.swift"
case .appBuilder: "AppBuilder.swift"
case .appOptions: "AppOptions.swift"
case .appTests: "AppTests.swift"
case .dockerFile: "Dockerfile"
case .dockerIgnore: ".dockerignore"
case .environment: "Environment+Properties.swift"
@@ -36,7 +31,6 @@ extension File {
case .license: "LICENSE"
case .loggerLevel: "LoggerLevel+Conformances.swift"
case .readme: "README.md"
case .package: "Package.swift"
case .testArguments: "TestArguments.swift"
}
}
@@ -47,9 +41,8 @@ extension File {
var folder: Folder {
switch self {
case .app, .appOptions: .app
case .appOptions: .app
case .appArguments, .appBuilder: .libraryPublic
case .appTests: .testCasesPublic
case .environment, .loggerLevel: .libraryInternal
case .testArguments: .testHelpers
default: .root
@@ -60,9 +53,9 @@ extension File {
let basePath = "Resources/Files/Sources"
return switch self {
case .app, .appOptions: "\(basePath)/App"
case .appOptions: "\(basePath)/App"
case .appArguments, .appBuilder, .environment, .loggerLevel: "\(basePath)/Library"
case .appTests, .testArguments: "\(basePath)/Test"
case .testArguments: "\(basePath)/Test"
default: basePath
}
}
@@ -0,0 +1,37 @@
enum Template: String {
case app = "App/App"
case appTests = "Test/AppTests"
case package = "Package"
}
// MARK: - Properties
extension Template {
// MARK: Computed
var fileName: String {
switch self {
case .app: "App.swift"
case .appTests: "AppTests.swift"
case .package: "Package.swift"
}
}
var filePath: String {
folder.path + fileName
}
var folder: Folder {
switch self {
case .app: .app
case .appTests: .testCasesPublic
default: .root
}
}
}
// MARK: - CaseIterable
extension Template: CaseIterable {}
@@ -1,75 +0,0 @@
import Foundation
struct RunProcessTask {
// MARK: Type aliases
typealias Output = String
// MARK: Properties
private var process: Processable
// MARK: Initialisers
init(process: Processable) {
self.process = process
}
// MARK: Functions
@discardableResult
mutating func callAsFunction(
path: String, arguments: [String] = []
) async throws (RunProcessError) -> Output {
process.executableURL = URL(at: path)
process.arguments = arguments
let pipeError = Pipe()
let pipeOutput = Pipe()
process.standardError = pipeError
process.standardOutput = pipeOutput
async let streamOutput = pipeOutput.availableData.append()
async let streamError = pipeError.availableData.append()
do {
try process.run()
let dataOutput = await streamOutput
let dataError = await streamError
guard dataError.isEmpty else {
guard let errorOutput = String(data: dataError, encoding: .utf8) else {
throw RunProcessError.unexpected
}
throw RunProcessError.output(errorOutput)
}
guard let output = String(data: dataOutput, encoding: .utf8) else {
throw RunProcessError.unexpected
}
return await withCheckedContinuation { continuation in
process.terminationHandler = { _ in
continuation.resume(returning: output)
}
}
} catch let error as RunProcessError {
throw error
} catch {
throw RunProcessError.captured(error.localizedDescription)
}
}
}
// MARK: - Errors
public enum RunProcessError: Error, Equatable {
case captured(_ output: String)
case output(_ output: String)
case unexpected
}
@@ -0,0 +1,13 @@
public struct Project: Equatable, Sendable {
// MARK: Properties
let name: String
// MARK: Initialisers
public init(name: String) {
self.name = name
}
}
@@ -1,6 +1,10 @@
import Foundation
public protocol Bundleable {
// MARK: Computed
var resourcePath: String? { get }
// MARK: Functions
@@ -9,6 +9,7 @@ public protocol FileServicing {
// MARK: Functions
func copyFile(from source: URL, to destination: URL) async throws (FileServiceError)
func createFile(at location: URL, with data: Data) async throws (FileServiceError)
func createFolder(at location: URL) async throws (FileServiceError)
func deleteItem(at location: URL) async throws (FileServiceError)
func isItemExists(at location: URL) async throws (FileServiceError) -> Bool
@@ -18,6 +19,8 @@ public protocol FileServicing {
// MARK: - Errors
public enum FileServiceError: Error, Equatable {
case fileDataIsEmpty
case fileNotCreated
case folderNotCreated
case itemAlreadyExists
case itemEmptyData
@@ -0,0 +1,16 @@
public protocol TemplateServicing {
// MARK: Functions
func render(_ object: Any, on template: String) async throws (TemplateServiceError) -> String
}
// MARK: - Errors
public enum TemplateServiceError: Error {
case contentNotRendered
case resourcePathNotFound
case serviceNotInitialized
case templateNotFound
}
@@ -0,0 +1,19 @@
import Foundation
public protocol TerminalServicing {
// MARK: Functions
@discardableResult
func run(_ executableURL: URL, arguments: [String]) async throws (TerminalServiceError) -> String
}
// MARK: - Errors
public enum TerminalServiceError: Error, Equatable {
case captured(_ output: String)
case output(_ output: String)
case unexpected
}
@@ -1,7 +1,7 @@
import Foundation
public struct FileService: FileServicing {
public struct FileService {
// MARK: Properties
private let fileManager: FileManager
@@ -12,6 +12,12 @@ public struct FileService: FileServicing {
self.fileManager = fileManager
}
}
// MARK: - FileServicing
extension FileService: FileServicing {
// MARK: Computed
public var currentFolder: URL {
@@ -24,7 +30,7 @@ public struct FileService: FileServicing {
public func copyFile(from source: URL, to destination: URL) async throws (FileServiceError) {
guard try await !isItemExists(at: destination) else {
throw FileServiceError.itemAlreadyExists
throw .itemAlreadyExists
}
var itemData: Data?
@@ -32,43 +38,59 @@ public struct FileService: FileServicing {
do {
itemData = try Data(contentsOf: source)
} catch {
throw FileServiceError.itemEmptyData
throw .itemEmptyData
}
do {
try itemData?.write(to: destination, options: .atomic)
} catch {
throw FileServiceError.itemNotCopied
throw .itemNotCopied
}
}
public func createFile(at location: URL, with data: Data) async throws (FileServiceError) {
guard try await !isItemExists(at: location) else {
throw .itemAlreadyExists
}
guard !data.isEmpty else {
throw .fileDataIsEmpty
}
do {
try data.write(to: location, options: .atomic)
} catch {
throw .fileNotCreated
}
}
public func createFolder(at location: URL) async throws (FileServiceError) {
guard try await !isItemExists(at: location) else {
throw FileServiceError.itemAlreadyExists
throw .itemAlreadyExists
}
do {
try fileManager.createDirectory(at: location, withIntermediateDirectories: true)
} catch {
throw FileServiceError.folderNotCreated
throw .folderNotCreated
}
}
public func deleteItem(at location: URL) async throws (FileServiceError) {
guard try await isItemExists(at: location) else {
throw FileServiceError.itemNotExists
throw .itemNotExists
}
do {
try fileManager.removeItem(at: location)
} catch {
throw FileServiceError.itemNotDeleted
throw .itemNotDeleted
}
}
public func isItemExists(at location: URL) async throws (FileServiceError) -> Bool {
guard location.isFileURL else {
throw FileServiceError.itemNotFileURL
throw .itemNotFileURL
}
let filePath = location.pathString
@@ -0,0 +1,50 @@
import Foundation
import Mustache
public struct TemplateService {
// MARK: Properties
private let mustacheRenderer: MustacheLibrary
// MARK: Initialisers
public init(
bundle: Bundleable? = nil,
templateFolder: String
) async throws (TemplateServiceError) {
guard let pathResources = (bundle ?? Bundle.module).resourcePath else {
throw .resourcePathNotFound
}
let pathTemplates = pathResources + "/" + templateFolder
do {
self.mustacheRenderer = try await MustacheLibrary(directory: pathTemplates)
} catch {
throw .serviceNotInitialized
}
}
}
// MARK: - TemplateServicing
extension TemplateService: TemplateServicing {
// MARK: Functions
public func render(_ object: Any, on template: String) async throws (TemplateServiceError) -> String {
guard mustacheRenderer.getTemplate(named: template) != nil else {
throw .templateNotFound
}
guard let content = mustacheRenderer.render(object, withTemplate: template) else {
throw .contentNotRendered
}
return content
}
}
@@ -0,0 +1,60 @@
import Foundation
public struct TerminalService {
// MARK: Initialisers
public init() {}
}
// MARK: - TerminalServicing
extension TerminalService: TerminalServicing {
// MARK: Functions
public func run(_ executableURL: URL, arguments: [String]) async throws (TerminalServiceError) -> String {
let process = Process()
let standardError = Pipe()
let standardOutput = Pipe()
process.executableURL = executableURL
process.arguments = arguments
process.standardError = standardError
process.standardOutput = standardOutput
async let streamOutput = standardOutput.availableData.append()
async let streamError = standardError.availableData.append()
do {
try process.run()
let dataOutput = await streamOutput
let dataError = await streamError
guard dataError.isEmpty else {
guard let errorOutput = String(data: dataError, encoding: .utf8) else {
throw TerminalServiceError.unexpected
}
throw TerminalServiceError.output(errorOutput)
}
guard let output = String(data: dataOutput, encoding: .utf8) else {
throw TerminalServiceError.unexpected
}
return await withCheckedContinuation { continuation in
process.terminationHandler = { _ in
continuation.resume(returning: output)
}
}
} catch let error as TerminalServiceError {
throw error
} catch {
throw .captured(error.localizedDescription)
}
}
}
@@ -1,24 +1,26 @@
import Foundation
public struct InitGitInFolderTask {
// MARK: Properties
private let terminalService: TerminalServicing
// MARK: Initialisers
public init() {}
public init(terminalService: TerminalServicing) {
self.terminalService = terminalService
}
// MARK: Functions
public func callAsFunction(at rootFolder: URL) async throws (RunProcessError) {
let pathCommand = "/usr/bin/git"
public func callAsFunction(at rootFolder: URL) async throws (TerminalServiceError) {
let executableURL = URL(at: "/usr/bin/git")
let pathFolder = rootFolder.pathString
var gitInit = RunProcessTask(process: Process())
var gitAdd = RunProcessTask(process: Process())
var gitCommit = RunProcessTask(process: Process())
try await gitInit(path: pathCommand, arguments: ["init", pathFolder])
try await gitAdd(path: pathCommand, arguments: ["-C", pathFolder, "add", "."])
try await gitCommit(path: pathCommand, arguments: ["-C", pathFolder, "commit", "-m", "Initial commit"])
try await terminalService.run(executableURL, arguments: ["init", pathFolder])
try await terminalService.run(executableURL, arguments: ["-C", pathFolder, "add", "."])
try await terminalService.run(executableURL, arguments: ["-C", pathFolder, "commit", "-m", "Initial commit"])
}
}
@@ -0,0 +1,34 @@
import Foundation
public struct RenderFilesTask {
// MARK: Computed
private let fileService: FileServicing
private let templateService: TemplateServicing
// MARK: Initialisers
public init(
fileService: FileServicing,
templateService: TemplateServicing
) {
self.fileService = fileService
self.templateService = templateService
}
// MARK: Functions
public func callAsFunction(
at rootFolder: URL,
with model: Project
) async throws {
for template in Template.allCases {
let content = try await templateService.render(model, on: template.rawValue)
let fileURL = rootFolder.appendingPath(template.filePath)
try await fileService.createFile(at: fileURL, with: Data(content.utf8))
}
}
}