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:
@@ -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.
|
||||
|
||||
+1
-1
@@ -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()
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "App",
|
||||
name: "{{ name }}",
|
||||
platforms: [
|
||||
.macOS(.v14)
|
||||
],
|
||||
+1
-1
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user