Basic project creation (#3)

This PR contains the work done to create a new *Hummingbird* project with very basic configuration from the _colibri_ executable, just like the project you could create with the [Hummingbird template](https://github.com/hummingbird-project/template) project in Github.

Reviewed-on: #3
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
Javier Cicchelli 2025-01-28 00:07:24 +00:00 committed by Javier Cicchelli
parent b8c354e614
commit 9be8fa4a31
52 changed files with 1936 additions and 475 deletions

View File

@ -1,5 +1,4 @@
import ArgumentParser
import ColibriLibrary
@main
struct Colibri: AsyncParsableCommand {

View File

@ -1,6 +1,5 @@
import ArgumentParser
import ColibriLibrary
import Foundation
extension Colibri {
struct Create: AsyncParsableCommand {
@ -20,36 +19,20 @@ extension Colibri {
mutating func run() async throws {
let fileService = FileService()
let copyFiles = CopyFilesTask(fileService: fileService)
let createFolders = CreateFoldersTask(fileService: fileService)
let createRootFolder = CreateRootFolderTask(fileService: fileService)
let initGitInFolder = InitGitInFolderTask()
let rootFolder = try await createRootFolder(
name: options.name,
at: options.locationURL
)
print(rootFolder)
}
}
}
// MARK: - Options
extension Colibri.Create {
struct Options: ParsableArguments {
// MARK: Properties
@Option(name: .shortAndLong)
var name: String
@Option(name: .shortAndLong)
var location: String?
// MARK: Computed
var locationURL: URL? {
location.flatMap { URL(fileURLWithPath: $0) }
try await createFolders(at: rootFolder)
try await copyFiles(to: rootFolder)
try await initGitInFolder(at: rootFolder)
}
}

View File

@ -0,0 +1,22 @@
import ArgumentParser
import Foundation
extension Colibri.Create {
struct Options: ParsableArguments {
// MARK: Properties
@Option(name: .shortAndLong)
var name: String
@Option(name: .shortAndLong)
var location: String?
// MARK: Computed
var locationURL: URL? {
location.flatMap { URL(fileURLWithPath: $0) }
}
}
}

View File

@ -0,0 +1,20 @@
import AppLibrary
import ArgumentParser
@main
struct App: AsyncParsableCommand {
// MARK: Properties
@OptionGroup var options: Options
// MARK: Functions
mutating func run() async throws {
let builder = AppBuilder(name: "App")
let app = try await builder(options)
try await app.runService()
}
}

View File

@ -0,0 +1,20 @@
import AppLibrary
import ArgumentParser
import Logging
extension App {
struct Options: AppArguments, ParsableArguments {
// MARK: Properties
@Option(name: .shortAndLong)
var hostname: String = "127.0.0.1"
@Option(name: .shortAndLong)
var port: Int = 8080
@Option(name: .shortAndLong)
var logLevel: Logger.Level?
}
}

View File

@ -0,0 +1,87 @@
# ================================
# Build image
# ================================
FROM swift:6.0-noble as build
# Install OS updates
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q update \
&& apt-get -q dist-upgrade -y \
&& apt-get install -y libjemalloc-dev \
&& rm -rf /var/lib/apt/lists/*
# Set up a build area
WORKDIR /build
# First just resolve dependencies.
# This creates a cached layer that can be reused
# as long as your Package.swift/Package.resolved
# files do not change.
COPY ./Package.* ./
RUN swift package resolve
# Copy entire repo into container
COPY . .
# Build the application, with optimizations, with static linking, and using jemalloc
RUN swift build -c release \
--product "App" \
--static-swift-stdlib \
-Xlinker -ljemalloc
# Switch to the staging area
WORKDIR /staging
# Copy main executable to staging area
RUN cp "$(swift build --package-path /build -c release --show-bin-path)/App" ./
# Copy static swift backtracer binary to staging area
RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
# Copy resources bundled by SPM to staging area
RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
# Copy any resources from the public directory and views directory if the directories exist
# Ensure that by default, neither the directory nor any of its contents are writable.
RUN [ -d /build/public ] && { mv /build/public ./public && chmod -R a-w ./public; } || true
# ================================
# Run image
# ================================
FROM ubuntu:noble
# Make sure all system packages are up to date, and install only essential packages.
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q update \
&& apt-get -q dist-upgrade -y \
&& apt-get -q install -y \
libjemalloc2 \
ca-certificates \
tzdata \
# If your app or its dependencies import FoundationNetworking, also install `libcurl4`.
# libcurl4 \
# If your app or its dependencies import FoundationXML, also install `libxml2`.
# libxml2 \
&& rm -r /var/lib/apt/lists/*
# Create a hummingbird user and group with /app as its home directory
RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app hummingbird
# Switch to the new home directory
WORKDIR /app
# Copy built executable and any staged resources from builder
COPY --from=build --chown=hummingbird:hummingbird /staging /app
# Provide configuration needed by the built-in crash reporter and some sensible default behaviors.
ENV SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static
# Ensure all further commands run as the hummingbird user
USER hummingbird:hummingbird
# Let Docker bind to port 8080
EXPOSE 8080
# Start the Hummingbird service when the image is run, default to listening on 8080 in production environment
ENTRYPOINT ["./App]
CMD ["--hostname", "0.0.0.0", "--port", "8080"]

View File

@ -0,0 +1,2 @@
.build
.git

View File

@ -0,0 +1,10 @@
.DS_Store
/.build
/.devContainer
/.swiftpm
/.vscode
/Packages
/*.xcodeproj
xcuserdata/
.env.*
.env

View File

@ -0,0 +1,11 @@
import Logging
public protocol AppArguments {
// MARK: Properties
var hostname: String { get }
var logLevel: Logger.Level? { get }
var port: Int { get }
}

View File

@ -0,0 +1,69 @@
import Hummingbird
import Logging
public struct AppBuilder {
// MARK: Properties
private let environment: Environment
private let name: String
// MARK: Initialisers
public init(name: String) {
self.environment = Environment()
self.name = name
}
// MARK: Functions
public func callAsFunction(
_ arguments: some AppArguments
) async throws -> some ApplicationProtocol {
let logger = {
var logger = Logger(label: name)
logger.logLevel = arguments.logLevel
?? environment.logLevel.flatMap { Logger.Level(rawValue: $0) ?? .info }
?? .info
return logger
}()
let router = router(logger: logger)
return Application(
router: router,
configuration: .init(
address: .hostname(arguments.hostname, port: arguments.port),
serverName: name
),
logger: logger
)
}
}
// MARK: - Helpers
private extension AppBuilder {
// MARK: Type aliases
typealias AppRequestContext = BasicRequestContext
// MARK: Functions
func router(logger: Logger) -> Router<AppRequestContext> {
let router = Router()
router.add(middleware: LogRequestsMiddleware(logger.logLevel))
router.get("/") { _,_ in
""
}
return router
}
}

View File

@ -0,0 +1,11 @@
import Hummingbird
extension Environment {
// MARK: Computed
public var logLevel: String? {
self.get("LOG_LEVEL")
}
}

View File

@ -0,0 +1,9 @@
import ArgumentParser
import Logging
/// Extend `Logger.Level` so it can be used as an argument
#if hasFeature(RetroactiveAttribute)
extension Logger.Level: @retroactive ExpressibleByArgument {}
#else
extension Logger.Level: ExpressibleByArgument {}
#endif

View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2024 Adam Fowler
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,45 @@
// swift-tools-version:6.0
import PackageDescription
let package = Package(
name: "App",
platforms: [
.macOS(.v14)
],
products: [
.executable(name: "App", targets: ["App"]),
.library(name: "AppLibrary", targets: ["AppLibrary"])
],
dependencies: [
.package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0")
],
targets: [
.executableTarget(
name: "App",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Hummingbird", package: "hummingbird"),
.target(name: "AppLibrary")
],
path: "App"
),
.target(
name: "AppLibrary",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Hummingbird", package: "hummingbird")
],
path: "Library"
),
.testTarget(
name: "AppTests",
dependencies: [
.product(name: "HummingbirdTesting", package: "hummingbird"),
.target(name: "AppLibrary")
],
path: "Test"
)
]
)

View File

@ -0,0 +1,10 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/hummingbird-project/hummingbird/assets/9382567/48de534f-8301-44bd-b117-dfb614909efd">
<img src="https://github.com/hummingbird-project/hummingbird/assets/9382567/e371ead8-7ca1-43e3-8077-61d8b5eab879">
</picture>
</p>
# Hummingbird project template
This is a template for your new [Hummingbird](https://wwww.hummingbird.codes) project.

View File

@ -0,0 +1,33 @@
import AppLibrary
import Hummingbird
import HummingbirdTesting
import Testing
struct AppTests {
// MARK: Properties
private let arguments = TestArguments()
private let builder = AppBuilder(name: "App")
// MARK: Route tests
@Test(arguments: ["/"])
func routes(_ uri: String) async throws {
let app = try await builder(arguments)
try await app.test(.router) { client in
try await client.execute(uri: uri, method: .get) { response in
#expect(response.status == .ok)
#expect(response.body == .empty)
}
}
}
}
// MARK: ByteBuffer+Constants
private extension ByteBuffer {
static let empty = ByteBuffer(string: "")
}

View File

@ -0,0 +1,12 @@
import AppLibrary
import Logging
struct TestArguments: AppArguments {
// MARK: Properties
let hostname = "127.0.0.1"
let port = 0
let logLevel: Logger.Level? = .trace
}

View File

@ -0,0 +1,74 @@
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"
}
// MARK: - Properties
extension File {
// MARK: Computed
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"
case .gitIgnore: ".gitignore"
case .license: "LICENSE"
case .loggerLevel: "LoggerLevel+Conformances.swift"
case .readme: "README.md"
case .package: "Package.swift"
case .testArguments: "TestArguments.swift"
}
}
var filePath: String {
folder.path + fileName
}
var folder: Folder {
switch self {
case .app, .appOptions: .app
case .appArguments, .appBuilder: .libraryPublic
case .appTests: .testCasesPublic
case .environment, .loggerLevel: .libraryInternal
case .testArguments: .testHelpers
default: .root
}
}
var resourcePath: String {
let basePath = "Resources/Files/Sources"
return switch self {
case .app, .appOptions: "\(basePath)/App"
case .appArguments, .appBuilder, .environment, .loggerLevel: "\(basePath)/Library"
case .appTests, .testArguments: "\(basePath)/Test"
default: basePath
}
}
}
// MARK: - CaseIterable
extension File: CaseIterable {}

View File

@ -0,0 +1,48 @@
enum Folder {
case app
case libraryPublic
case libraryInternal
case root
case testCasesPublic
case testCasesInternal
case testHelpers
}
// MARK: - Properties
extension Folder {
// MARK: Computed
var path: String {
switch self {
case .app: "App/Sources/"
case .libraryPublic: "Library/Sources/Public/"
case .libraryInternal: "Library/Sources/Internal/"
case .root: ""
case .testCasesPublic: "Test/Sources/Cases/Public/"
case .testCasesInternal: "Test/Sources/Cases/Internal/"
case .testHelpers: "Test/Sources/Helpers/"
}
}
}
// MARK: - CaseIterable
extension Folder: CaseIterable {
// MARK: Properties
static var allCases: [Folder] {[
.app,
.libraryPublic,
.libraryInternal,
.testCasesPublic,
.testCasesInternal,
.testHelpers
]}
static var allCasesWithRoot: [Folder] { [.root] + Folder.allCases }
}

View File

@ -0,0 +1,5 @@
import Foundation
// MARK: - Bundleable
extension Bundle: Bundleable {}

View File

@ -0,0 +1,72 @@
import Foundation
extension Pipe {
// MARK: Computed
var availableData: AsyncAvailableData { .init(self) }
}
// MARK: - AsyncAvailableData
extension Pipe {
struct AsyncAvailableData {
// MARK: Properties
private let pipe: Pipe
// MARK: Initialisers
init(_ pipe: Pipe) {
self.pipe = pipe
}
// MARK: Functions
func append() async -> Data {
var data = Data()
for await availableData in self {
data.append(availableData)
}
return data
}
}
}
// MARK: - AsyncSequence
extension Pipe.AsyncAvailableData: AsyncSequence {
// MARK: Type aliases
typealias AsyncIterator = AsyncStream<Data>.Iterator
typealias Element = Data
// MARK: Functions
func makeAsyncIterator() -> AsyncIterator {
AsyncStream { continuation in
pipe.fileHandleForReading.readabilityHandler = { @Sendable handler in
let data = handler.availableData
guard !data.isEmpty else {
continuation.finish()
return
}
continuation.yield(data)
}
continuation.onTermination = { _ in
pipe.fileHandleForReading.readabilityHandler = nil
}
}
.makeAsyncIterator()
}
}

View File

@ -0,0 +1,5 @@
import Foundation
// MARK: - Processable
extension Process: Processable {}

View File

@ -0,0 +1,35 @@
import Foundation
extension URL {
// MARK: Initialisers
init(at filePath: String) {
if #available(macOS 13.0, *) {
self = URL(filePath: filePath)
} else {
self = URL(fileURLWithPath: filePath)
}
}
// MARK: Computed
var pathString: String {
if #available(macOS 13.0, *) {
path(percentEncoded: true)
} else {
path
}
}
// MARK: Functions
func appendingPath(_ path: String) -> URL {
if #available(macOS 13.0, *) {
appending(path: path)
} else {
appendingPathComponent(path)
}
}
}

View File

@ -0,0 +1,17 @@
import Foundation
protocol Processable {
// MARK: Properties
var arguments: [String]? { get set }
var executableURL: URL? { get set }
var standardError: Any? { get set }
var standardOutput: Any? { get set }
var terminationHandler: (@Sendable (Process) -> Void)? { get set }
// MARK: Functions
func run() throws
}

View File

@ -0,0 +1,75 @@
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
}

View File

@ -0,0 +1,9 @@
import Foundation
public protocol Bundleable {
// MARK: Functions
func url(forResource name: String?, withExtension ext: String?, subdirectory subpath: String?) -> URL?
}

View File

@ -0,0 +1,28 @@
import Foundation
public protocol FileServicing {
// MARK: Computed
var currentFolder: URL { get async }
// MARK: Functions
func copyFile(from source: URL, to destination: URL) 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
}
// MARK: - Errors
public enum FileServiceError: Error, Equatable {
case folderNotCreated
case itemAlreadyExists
case itemEmptyData
case itemNotCopied
case itemNotDeleted
case itemNotExists
case itemNotFileURL
}

View File

@ -0,0 +1,79 @@
import Foundation
public struct FileService: FileServicing {
// MARK: Properties
private let fileManager: FileManager
// MARK: Initialisers
public init(fileManager: FileManager = .default) {
self.fileManager = fileManager
}
// MARK: Computed
public var currentFolder: URL {
get async {
.init(at: fileManager.currentDirectoryPath)
}
}
// MARK: Functions
public func copyFile(from source: URL, to destination: URL) async throws (FileServiceError) {
guard try await !isItemExists(at: destination) else {
throw FileServiceError.itemAlreadyExists
}
var itemData: Data?
do {
itemData = try Data(contentsOf: source)
} catch {
throw FileServiceError.itemEmptyData
}
do {
try itemData?.write(to: destination, options: .atomic)
} catch {
throw FileServiceError.itemNotCopied
}
}
public func createFolder(at location: URL) async throws (FileServiceError) {
guard try await !isItemExists(at: location) else {
throw FileServiceError.itemAlreadyExists
}
do {
try fileManager.createDirectory(at: location, withIntermediateDirectories: true)
} catch {
throw FileServiceError.folderNotCreated
}
}
public func deleteItem(at location: URL) async throws (FileServiceError) {
guard try await isItemExists(at: location) else {
throw FileServiceError.itemNotExists
}
do {
try fileManager.removeItem(at: location)
} catch {
throw FileServiceError.itemNotDeleted
}
}
public func isItemExists(at location: URL) async throws (FileServiceError) -> Bool {
guard location.isFileURL else {
throw FileServiceError.itemNotFileURL
}
let filePath = location.pathString
return fileManager.fileExists(atPath: filePath)
}
}

View File

@ -0,0 +1,39 @@
import Foundation
public struct CopyFilesTask {
// MARK: Properties
private let bundle: Bundleable
private let fileService: FileServicing
// MARK: Initialisers
public init(
bundle: Bundleable? = nil,
fileService: FileServicing
) {
self.bundle = bundle ?? Bundle.module
self.fileService = fileService
}
// MARK: Functions
public func callAsFunction(to rootFolder: URL) async throws (FileServiceError) {
for file in File.allCases {
guard let source = bundle.url(
forResource: file.rawValue,
withExtension: nil,
subdirectory: file.resourcePath
) else {
assertionFailure("URL should have been initialized.")
return
}
let destination = rootFolder.appendingPath(file.filePath)
try await fileService.copyFile(from: source, to: destination)
}
}
}

View File

@ -0,0 +1,25 @@
import Foundation
public struct CreateFoldersTask {
// MARK: Properties
private let fileService: FileServicing
// MARK: Initialisers
public init(fileService: FileServicing) {
self.fileService = fileService
}
// MARK: Functions
public func callAsFunction(at rootFolder: URL) async throws {
let folders = Folder.allCases.map { rootFolder.appendingPath($0.path) }
for folder in folders {
try await fileService.createFolder(at: folder)
}
}
}

View File

@ -14,10 +14,7 @@ public struct CreateRootFolderTask {
// MARK: Functions
public func callAsFunction(
name: String,
at location: URL? = nil
) async throws -> URL {
public func callAsFunction(name: String, at location: URL? = nil) async throws -> URL {
guard !name.isEmpty else {
throw CreateRootFolderError.nameIsEmpty
}
@ -28,11 +25,7 @@ public struct CreateRootFolderTask {
await fileService.currentFolder
}
let newFolder = if #available(macOS 13.0, *) {
rootFolder.appending(path: name)
} else {
rootFolder.appendingPathComponent(name)
}
let newFolder = rootFolder.appendingPath(name)
try await fileService.createFolder(at: newFolder)

View File

@ -0,0 +1,24 @@
import Foundation
public struct InitGitInFolderTask {
// MARK: Initialisers
public init() {}
// MARK: Functions
public func callAsFunction(at rootFolder: URL) async throws (RunProcessError) {
let pathCommand = "/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"])
}
}

View File

@ -8,42 +8,35 @@ let package = Package(
.macOS(.v10_15)
],
products: [
.executable(
name: "colibri",
targets: ["Colibri"]
),
.library(
name: "ColibriLibrary",
targets: ["ColibriLibrary"]
)
.executable(name: "colibri", targets: ["Colibri"]),
.library(name: "ColibriLibrary", targets: ["ColibriLibrary"])
],
dependencies: [
.package(
url: "https://github.com/apple/swift-argument-parser",
from: "1.0.0"
)
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0")
],
targets: [
.executableTarget(
name: "Colibri",
dependencies: [
.product(
name: "ArgumentParser",
package: "swift-argument-parser"
),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.target(name: "ColibriLibrary")
],
path: "Sources/Executable"
path: "Executable"
),
.target(
name: "ColibriLibrary",
dependencies: [],
path: "Sources/Library"
path: "Library",
resources: [
.copy("Resources")
]
),
.testTarget(
name: "ColibriTests",
dependencies: ["ColibriLibrary"],
path: "Tests/Library"
dependencies: [
.target(name: "ColibriLibrary")
],
path: "Test"
)
]
)

View File

@ -1 +0,0 @@
import Foundation

View File

@ -1,15 +0,0 @@
import Foundation
extension URL {
// MARK: Initialisers
init(at filePath: String) {
if #available(macOS 13.0, *) {
self = URL(filePath: filePath)
} else {
self = URL(fileURLWithPath: filePath)
}
}
}

View File

@ -1,25 +0,0 @@
import Foundation
public protocol FileServicing {
// MARK: Computed
var currentFolder: URL { get async }
// MARK: Functions
func createFolder(at url: URL) async throws (FileServiceError)
func delete(at url: URL) async throws (FileServiceError)
func exists(at url: URL) async throws (FileServiceError) -> Bool
}
// MARK: - Errors
public enum FileServiceError: Error, Equatable {
case folderNotCreated
case urlAlreadyExists
case urlNotDeleted
case urlNotExists
case urlNotFileURL
}

View File

@ -1,78 +0,0 @@
import Foundation
public struct FileService: FileServicing {
// MARK: Properties
private let fileManager: FileManager
// MARK: Initialisers
public init(fileManager: FileManager = .default) {
self.fileManager = fileManager
}
// MARK: Computed
public var currentFolder: URL {
get async {
.init(at: fileManager.currentDirectoryPath)
}
}
// MARK: Functions
public func createFolder(at url: URL) async throws (FileServiceError) {
guard try await !exists(at: url) else {
throw FileServiceError.urlAlreadyExists
}
do {
try fileManager.createDirectory(
at: url,
withIntermediateDirectories: true
)
} catch {
throw FileServiceError.folderNotCreated
}
}
public func delete(at url: URL) async throws (FileServiceError) {
guard try await exists(at: url) else {
throw FileServiceError.urlNotExists
}
do {
try fileManager.removeItem(at: url)
} catch {
throw FileServiceError.urlNotDeleted
}
}
public func exists(at url: URL) async throws (FileServiceError) -> Bool {
guard url.isFileURL else {
throw FileServiceError.urlNotFileURL
}
let filePath = getPath(for: url)
return fileManager.fileExists(atPath: filePath)
}
}
// MARK: - Helpers
private extension FileService {
// MARK: Functions
func getPath(for url: URL) -> String {
if #available(macOS 13.0, *) {
return url.path()
} else {
return url.path
}
}
}

View File

@ -0,0 +1,125 @@
import Testing
@testable import ColibriLibrary
struct FileTests {
// MARK: Properties tests
@Test(arguments: zip(File.allCases, Expectation.fileNames))
func fileName(for file: File, expects fileName: String) async throws {
// GIVEN
// WHEN
let result = file.fileName
// THEN
#expect(result == fileName)
}
@Test(arguments: zip(File.allCases, Expectation.filePaths))
func filePath(for file: File, expects filePath: String) async throws {
// GIVEN
// WHEN
let result = file.filePath
// THEN
#expect(result == filePath)
}
@Test(arguments: zip(File.allCases, Expectation.folders))
func folder(for file: File, expects folder: Folder) async throws {
// GIVEN
// WHEN
let result = file.folder
// THEN
#expect(result == folder)
}
@Test(arguments: zip(File.allCases, Expectation.resourcePaths))
func resourcePath(for file: File, expects resourcePath: String) async throws {
// GIVEN
// WHEN
let result = file.resourcePath
// THEN
#expect(result == resourcePath)
}
}
// MARK: - Expectations
private extension FileTests {
enum Expectation {
static let fileNames: [String] = [
"App.swift",
"AppArguments.swift",
"AppBuilder.swift",
"AppOptions.swift",
"AppTests.swift",
"Dockerfile",
".dockerignore",
"Environment+Properties.swift",
".gitignore",
"LICENSE",
"LoggerLevel+Conformances.swift",
"Package.swift",
"README.md",
"TestArguments.swift"
]
static let filePaths: [String] = [
"App/Sources/App.swift",
"Library/Sources/Public/AppArguments.swift",
"Library/Sources/Public/AppBuilder.swift",
"App/Sources/AppOptions.swift",
"Test/Sources/Cases/Public/AppTests.swift",
"Dockerfile",
".dockerignore",
"Library/Sources/Internal/Environment+Properties.swift",
".gitignore",
"LICENSE",
"Library/Sources/Internal/LoggerLevel+Conformances.swift",
"Package.swift",
"README.md",
"Test/Sources/Helpers/TestArguments.swift"
]
static let folders: [Folder] = [
.app,
.libraryPublic,
.libraryPublic,
.app,
.testCasesPublic,
.root,
.root,
.libraryInternal,
.root,
.root,
.libraryInternal,
.root,
.root,
.testHelpers
]
static let resourcePaths: [String] = [
"Resources/Files/Sources/App",
"Resources/Files/Sources/Library",
"Resources/Files/Sources/Library",
"Resources/Files/Sources/App",
"Resources/Files/Sources/Test",
"Resources/Files/Sources",
"Resources/Files/Sources",
"Resources/Files/Sources/Library",
"Resources/Files/Sources",
"Resources/Files/Sources",
"Resources/Files/Sources/Library",
"Resources/Files/Sources",
"Resources/Files/Sources",
"Resources/Files/Sources/Test"
]
}
}

View File

@ -0,0 +1,35 @@
import Testing
@testable import ColibriLibrary
struct FolderTests {
// MARK: Properties tests
@Test(arguments: zip(Folder.allCasesWithRoot, Expectation.paths))
func paths(for folder: Folder, expects path: String) async throws {
// GIVEN
// WHEN
let result = folder.path
// THEN
#expect(result == path)
}
}
// MARK: - Expectations
private extension FolderTests {
enum Expectation {
static let paths: [String] = [
"",
"App/Sources/",
"Library/Sources/Public/",
"Library/Sources/Internal/",
"Test/Sources/Cases/Public/",
"Test/Sources/Cases/Internal/",
"Test/Sources/Helpers/"
]
}
}

View File

@ -0,0 +1,76 @@
import Foundation
import Testing
@testable import ColibriLibrary
struct URL_ExtensionsTests {
// MARK: Initialisers tests
@Test(arguments: zip([String.someFilePath, .dotPath, .tildePath],
[URL.someFile, .dotFile, .tildeFile]))
func initAt(
with filePath: String,
expects url: URL
) async throws {
// GIVEN
// WHEN
let result = URL(at: filePath)
// THEN
#expect(result == url)
#expect(result.isFileURL == true)
}
// MARK: Computed tests
@Test(arguments: zip([URL.someFile, .dotFile, .tildeFile, .someURL],
[String.someFilePath, .dotPath, .tildePath, .empty]))
func pathString(
with url: URL,
expects path: String
) async throws {
// GIVEN
// WHEN
let result = url.pathString
// THEN
#expect(result == path)
}
// MARK: Functions tests
@Test(arguments: zip([URL.dotFile, .tildeFile, .someFile],
[".\(String.someFilePath)", "~\(String.someFilePath)", "\(String.someFilePath)\(String.someFilePath)"]))
func appendingPath(
with url: URL,
expects path: String
) async throws {
// GIVEN
// WHEN
let result = url.appendingPath(.someFilePath)
// THEN
#expect(result.pathString == path)
#expect(result.isFileURL == true)
}
}
// MARK: - String+Constants
private extension String {
static let dotPath = "."
static let empty = ""
static let tildePath = "~"
static let someFilePath = "/some/file/path"
}
// MARK: - URL+Constants
private extension URL {
static let dotFile = URL(at: .dotPath)
static let someFile = URL(at: .someFilePath)
static let someURL = URL(string: "https://some.url.path")!
static let tildeFile = URL(at: .tildePath)
}

View File

@ -0,0 +1,68 @@
import Foundation
import Testing
@testable import ColibriLibrary
struct RunProcessTaskTests {
// MARK: Properties
private var process: Process
// MARK: Initialisers
init() {
self.process = Process()
}
// MARK: Functions tests
@Test(arguments: [Argument.empty, Argument.listAllInFolder])
func run(with arguments: [String]) async throws {
// GIVEN
var task = RunProcessTask(process: process)
// WHEN
let output = try await task(path: .ls, arguments: arguments)
// THEN
#expect(output.isEmpty == false)
}
@Test(arguments: zip([Argument.help, Argument.listAllInPWD], Throw.outputs))
func runThrows(with arguments: [String], throws error: RunProcessError) async throws {
// GIVEN
var task = RunProcessTask(process: process)
// WHEN
// THEN
await #expect(throws: error) {
try await task(path: .ls, arguments: arguments)
}
}
}
// MARK: - String+Constants
private extension String {
static let ls = "/bin/ls"
}
// MARK: - Parameters
private extension RunProcessTaskTests {
enum Argument {
static let empty: [String] = []
static let help: [String] = ["--help"]
static let listAllInFolder: [String] = ["-la", "."]
static let listAllInPWD: [String] = ["-la", "~"]
}
enum Throw {
static let outputs: [RunProcessError] = [
.output("ls: unrecognized option `--help\'\nusage: ls [-@ABCFGHILOPRSTUWXabcdefghiklmnopqrstuvwxy1%,] [--color=when] [-D format] [file ...]\n"),
.output("ls: ~: No such file or directory\n")
]
}
}

View File

@ -0,0 +1,181 @@
import ColibriLibrary
import Foundation
import Testing
struct FileServiceTests {
// MARK: Properties
private let spy = FileServiceSpy()
// MARK: Properties tests
@Test func currentFolder() async {
// GIVEN
let url: URL = .someCurrentFolder
let service = FileServiceMock(currentFolder: url)
// WHEN
let folder = await service.currentFolder
// THEN
#expect(folder == url)
#expect(folder.isFileURL == true)
}
// MARK: Functions tests
@Test(arguments: zip([URL.someExistingFile, .someExistingFolder],
[URL.someNewFile, .someNewFolder]))
func copyFile(from source: URL, to destination: URL) async throws {
// GIVEN
let service = service(action: .copyFile(source, destination))
// WHEN
try await service.copyFile(from: source, to: destination)
// THENn
#expect(spy.actions.count == 1)
let action = try #require(spy.actions.last)
#expect(action == .fileCopied(source, destination))
}
@Test(arguments: [FileServiceError.itemAlreadyExists, .itemEmptyData, .itemNotCopied])
func copyItem(throws error: FileServiceError) async throws {
// GIVEN
let service = service(action: .error(error))
// WHEN
// THEN
await #expect(throws: error) {
try await service.copyFile(from: .someExistingFile, to: .someNewFile)
}
#expect(spy.actions.isEmpty == true)
}
@Test(arguments: [URL.someNewFolder, .someNewFile])
func createFolder(with location: URL) async throws {
// GIVEN
let service = service(action: .createFolder(location))
// WHEN
try await service.createFolder(at: location)
// THEN
#expect(spy.actions.count == 1)
let action = try #require(spy.actions.last)
#expect(action == .folderCreated(location))
}
@Test(arguments: zip([URL.someExistingFolder, .someExistingFile, .someRandomURL],
[FileServiceError.itemAlreadyExists, .itemAlreadyExists, .itemNotFileURL]))
func createFolder(
with location: URL,
throws error: FileServiceError
) async throws {
// GIVEN
let service = service(action: .error(error))
// WHEN
// THEN
await #expect(throws: error) {
try await service.createFolder(at: location)
}
#expect(spy.actions.isEmpty == true)
}
@Test(arguments: [URL.someNewFolder, .someNewFile])
func deleteItem(with location: URL) async throws {
// GIVEN
let service = service(action: .deleteItem(location))
// WHEN
try await service.deleteItem(at: location)
// THEN
#expect(spy.actions.count == 1)
let action = try #require(spy.actions.last)
#expect(action == .itemDeleted(location))
}
@Test(arguments: zip([URL.someNewFolder, .someNewFile, .someRandomURL],
[FileServiceError.itemNotExists, .itemNotExists, .itemNotFileURL]))
func deleteItem(
with location: URL,
throws error: FileServiceError
) async throws {
// GIVEN
let service = service(action: .error(error))
// WHEN
// THEN
await #expect(throws: error) {
try await service.deleteItem(at: location)
}
#expect(spy.actions.isEmpty == true)
}
@Test(arguments: zip([URL.someExistingFolder, .someExistingFile, .someNewFolder, .someNewFile],
[true, true, false, false]))
func isItemExists(
with location: URL,
expects outcome: Bool
) async throws {
// GIVEN
let service = service(action: .isItemExists(location, outcome))
// WHEN
let result = try await service.isItemExists(at: location)
// THEN
#expect(result == outcome)
let action = try #require(spy.actions.last)
#expect(action == .itemExists(location))
}
@Test(arguments: zip([URL.someRandomURL], [FileServiceError.itemNotFileURL]))
func isItemExists(
with location: URL,
throws error: FileServiceError
) async throws {
// GIVEN
let service = service(action: .error(error))
// WHEN
// THEN
await #expect(throws: error) {
try await service.isItemExists(at: location)
}
#expect(spy.actions.isEmpty == true)
}
}
// MARK: - Helpers
private extension FileServiceTests {
// MARK: Functions
func service(action: FileServiceMock.Action) -> FileServiceMock {
.init(
currentFolder: .someCurrentFolder,
action: action,
spy: spy
)
}
}

View File

@ -0,0 +1,80 @@
import Foundation
import Testing
@testable import ColibriLibrary
struct CopyFilesTaskTests {
// MARK: Properties
private let resourceFolder = URL.someExistingFolder
private let rootFolder = URL.someNewFolder
private let spy = FileServiceSpy()
// MARK: Functions tests
@Test func copyFiles() async throws {
// GIVEN
let files = files(of: File.allCases)
let actions = files.map { FileServiceMock.Action.copyFile($0.source, $0.destination) }
let copyFiles = task(actions: actions)
// WHEN
try await copyFiles(to: rootFolder)
// THEN
#expect(spy.actions.count == actions.count)
files.enumerated().forEach { index, file in
#expect(spy.actions[index] == .fileCopied(file.source, file.destination))
}
}
@Test(arguments: [FileServiceError.itemAlreadyExists, .itemEmptyData, .itemNotCopied])
func copyFiles(throws error: FileServiceError) async throws {
// GIVEN
let files = files(of: Array(File.allCases[0...2]))
let actions = files.map { FileServiceMock.Action.copyFile($0.source, $0.destination) }
let copyFiles = task(actions: actions + [.error(error)])
// WHEN
// THEN
await #expect(throws: error) {
try await copyFiles(to: rootFolder)
}
#expect(spy.actions.count == actions.count)
files.enumerated().forEach { index, file in
#expect(spy.actions[index] == .fileCopied(file.source, file.destination))
}
}
}
// MARK: - Helpers
private extension CopyFilesTaskTests {
// MARK: Type aliases
typealias FileURL = (source: URL, destination: URL)
// MARK: Functions
func files(of resourceFiles: [File]) -> [FileURL] {
resourceFiles.map { (resourceFolder.appendingPath($0.rawValue), rootFolder.appendingPath($0.fileName)) }
}
func task(actions: [FileServiceMock.Action]) -> CopyFilesTask {
.init(fileService: FileServiceMock(
currentFolder: .someCurrentFolder,
actions: actions,
spy: spy
))
}
}

View File

@ -0,0 +1,49 @@
import Foundation
import Testing
@testable import ColibriLibrary
struct CreateFoldersTaskTests {
// MARK: Properties
private let spy = FileServiceSpy()
// MARK: Functions tests
@Test(arguments: [URL.someCurrentFolder, .someDotFolder, .someTildeFolder])
func createFolders(with rootFolder: URL) async throws {
// GIVEN
let folders = Folder.allCases.map { rootFolder.appendingPath($0.path) }
let actions = folders.map { FileServiceMock.Action.createFolder($0) }
let createFolders = task(actions: actions)
// WHEN
try await createFolders(at: rootFolder)
// THEN
#expect(spy.actions.count == actions.count)
for index in actions.indices {
#expect(spy.actions[index] == .folderCreated(folders[index]))
}
}
}
// MARK: - Helpers
private extension CreateFoldersTaskTests {
// MARK: Functions
func task(actions: [FileServiceMock.Action]) -> CreateFoldersTask {
.init(fileService: FileServiceMock(
currentFolder: .someCurrentFolder,
actions: actions,
spy: spy
))
}
}

View File

@ -1,7 +1,8 @@
import ColibriLibrary
import Foundation
import Testing
@testable import ColibriLibrary
struct CreateRootFolderTaskTests {
// MARK: Functions tests
@ -19,12 +20,10 @@ struct CreateRootFolderTaskTests {
default: nil
}
let fileService = FileServiceMock(
let task = CreateRootFolderTask(fileService: FileServiceMock(
currentFolder: .someCurrentFolder,
action: .createFolder(folder)
)
let task = CreateRootFolderTask(fileService: fileService)
))
// WHEN
let result = try await task(name: name,
@ -35,18 +34,16 @@ struct CreateRootFolderTaskTests {
#expect(result.isFileURL == true)
}
@Test(arguments: [String.someProjectName], [FileServiceError.urlAlreadyExists])
@Test(arguments: [String.someProjectName], [FileServiceError.itemAlreadyExists])
func task(
name: String,
throws error: FileServiceError
) async throws {
// GIVEN
let fileService = FileServiceMock(
let task = CreateRootFolderTask(fileService: FileServiceMock(
currentFolder: .someCurrentFolder,
action: .error(error)
)
let task = CreateRootFolderTask(fileService: fileService)
))
// WHEN
// THEN
@ -61,9 +58,9 @@ struct CreateRootFolderTaskTests {
throws error: CreateRootFolderError
) async throws {
// GIVEN
let fileService = FileServiceMock(currentFolder: .someCurrentFolder)
let task = CreateRootFolderTask(fileService: fileService)
let task = CreateRootFolderTask(fileService: FileServiceMock(
currentFolder: .someCurrentFolder
))
// WHEN
// THEN
@ -84,8 +81,8 @@ private extension String {
// MARK: - URL+Constants
private extension URL {
static let someCurrentProjectFolder = URL.someCurrentFolder.appending(component: String.someProjectName)
static let someDotProjectFolder = URL.someDotFolder.appending(component: String.someProjectName)
static let someNewProjectFolder = URL.someNewFolder.appending(component: String.someProjectName)
static let someTildeProjectFolder = URL.someTildeFolder.appending(component: String.someProjectName)
static let someCurrentProjectFolder = URL.someCurrentFolder.appendingPath(.someProjectName)
static let someDotProjectFolder = URL.someDotFolder.appendingPath(.someProjectName)
static let someNewProjectFolder = URL.someNewFolder.appendingPath(.someProjectName)
static let someTildeProjectFolder = URL.someTildeFolder.appendingPath(.someProjectName)
}

View File

@ -0,0 +1,135 @@
import ColibriLibrary
import Foundation
final class FileServiceMock {
// MARK: Properties
private let folder: URL
private var actions: [Action] = []
private weak var spy: FileServiceSpy?
// MARK: Initialisers
init(
currentFolder: URL,
action: Action? = nil,
spy: FileServiceSpy? = nil
) {
self.actions = if let action {
[action]
} else {
[]
}
self.folder = currentFolder
self.spy = spy
}
init(
currentFolder: URL,
actions: [Action],
spy: FileServiceSpy? = nil
) {
self.actions = actions
self.folder = currentFolder
self.spy = spy
}
}
// MARK: - FileServicing
extension FileServiceMock: FileServicing {
// MARK: Computed
var currentFolder: URL {
get async { folder }
}
// MARK: Functions
func copyFile(from source: URL, to destination: URL) async throws (FileServiceError) {
guard let nextAction else { return }
switch nextAction {
case .error(let error):
throw error
case let .copyFile(source, destination):
try await spy?.copyFile(from: source, to: destination)
default:
break
}
}
func createFolder(at location: URL) async throws (FileServiceError) {
guard let nextAction else { return }
switch nextAction {
case .error(let error):
throw error
case let .createFolder(location):
try await spy?.createFolder(at: location)
default:
break
}
}
func deleteItem(at location: URL) async throws (FileServiceError) {
guard let nextAction else { return }
switch nextAction {
case .error(let error):
throw error
case let .deleteItem(location):
try await spy?.deleteItem(at: location)
default:
break
}
}
func isItemExists(at location: URL) async throws (FileServiceError) -> Bool {
guard let nextAction else { return false }
switch nextAction {
case .error(let error):
throw error
case let .isItemExists(location, exists):
try await spy?.isItemExists(at: location)
return exists
default:
return false
}
}
}
// MARK: - Helpers
private extension FileServiceMock {
// MARK: Computed
var nextAction: Action? {
guard !actions.isEmpty else {
return nil
}
return actions.removeFirst()
}
}
// MARK: - Actions
extension FileServiceMock {
enum Action {
case copyFile(URL, URL)
case createFolder(URL)
case deleteItem(URL)
case error(FileServiceError)
case isItemExists(URL, Bool)
}
}

View File

@ -0,0 +1,51 @@
import Foundation
@testable import ColibriLibrary
final class FileServiceSpy {
// MARK: Properties
private(set) var actions: [Action] = []
}
// MARK: - FileServicing
extension FileServiceSpy: FileServicing {
var currentFolder: URL {
get async { .someCurrentFolder }
}
func copyFile(from source: URL, to destination: URL) async throws (FileServiceError) {
actions.append(.fileCopied(source, destination))
}
func createFolder(at location: URL) async throws (FileServiceError) {
actions.append(.folderCreated(location))
}
func deleteItem(at location: URL) async throws (FileServiceError) {
actions.append(.itemDeleted(location))
}
@discardableResult
func isItemExists(at location: URL) async throws (FileServiceError) -> Bool {
actions.append(.itemExists(location))
return .random()
}
}
// MARK: - Action
extension FileServiceSpy {
enum Action: Equatable {
case fileCopied(_ source: URL, _ destination: URL)
case folderCreated(_ location: URL)
case itemDeleted(_ location: URL)
case itemExists(_ location: URL)
}
}

View File

@ -1,154 +0,0 @@
import ColibriLibrary
import Foundation
import Testing
struct FileServiceTests {
// MARK: Properties
private let spy = FileServiceSpy()
// MARK: Properties tests
@Test func currentFolder() async {
// GIVEN
let url: URL = .someCurrentFolder
let service = FileServiceMock(currentFolder: url)
// WHEN
let folder = await service.currentFolder
// THEN
#expect(folder == url)
#expect(folder.isFileURL == true)
}
// MARK: Functions
@Test(arguments: [URL.someNewFolder, .someNewFile])
func createFolder(with url: URL) async throws {
// GIVEN
let service = FileServiceMock(
currentFolder: .someCurrentFolder,
action: .createFolder(url),
spy: spy
)
// WHEN
try await service.createFolder(at: url)
// THEN
#expect(spy.isCreateFolderCalled == true)
#expect(spy.urlCalled == url)
}
@Test(arguments: zip([URL.someExistingFolder, .someExistingFile, .someRandomURL],
[FileServiceError.urlAlreadyExists, .urlAlreadyExists, .urlNotFileURL]))
func createFolder(
with url: URL,
throws error: FileServiceError
) async throws {
// GIVEN
let service = FileServiceMock(
currentFolder: .someCurrentFolder,
action: .error(error),
spy: spy
)
// WHEN
// THEN
await #expect(throws: error) {
try await service.createFolder(at: url)
}
#expect(spy.isCreateFolderCalled == false)
#expect(spy.urlCalled == nil)
}
@Test(arguments: [URL.someNewFolder, .someNewFile])
func delete(with url: URL) async throws {
// GIVEN
let service = FileServiceMock(
currentFolder: .someCurrentFolder,
action: .delete(url),
spy: spy
)
// WHEN
try await service.delete(at: url)
// THEN
#expect(spy.isDeleteCalled == true)
#expect(spy.urlCalled == url)
}
@Test(arguments: zip([URL.someNewFolder, .someNewFile, .someRandomURL],
[FileServiceError.urlNotExists, .urlNotExists, .urlNotFileURL]))
func delete(
with url: URL,
throws error: FileServiceError
) async throws {
// GIVEN
let service = FileServiceMock(
currentFolder: .someCurrentFolder,
action: .error(error),
spy: spy
)
// WHEN
// THEN
await #expect(throws: error) {
try await service.delete(at: url)
}
#expect(spy.isDeleteCalled == false)
#expect(spy.urlCalled == nil)
}
@Test(arguments: zip([URL.someExistingFolder, .someExistingFile, .someNewFolder, .someNewFile],
[true, true, false, false]))
func exists(
with url: URL,
expects outcome: Bool
) async throws {
// GIVEN
let service = FileServiceMock(
currentFolder: .someCurrentFolder,
action: .exists(url, outcome),
spy: spy
)
// WHEN
let result = try await service.exists(at: url)
// THEN
#expect(result == outcome)
#expect(spy.isExistsAtCalled == true)
#expect(spy.urlCalled == url)
}
@Test(arguments: zip([URL.someRandomURL], [FileServiceError.urlNotFileURL]))
func exists(
with url: URL,
throws error: FileServiceError
) async throws {
// GIVEN
let service = FileServiceMock(
currentFolder: .someCurrentFolder,
action: .error(error),
spy: spy
)
// WHEN
// THEN
await #expect(throws: error) {
try await service.exists(at: url)
}
#expect(spy.isExistsAtCalled == false)
#expect(spy.urlCalled == nil)
}
}

View File

@ -1,3 +0,0 @@
import Testing
struct ColibriLibraryTests {}

View File

@ -1,84 +0,0 @@
import ColibriLibrary
import Foundation
struct FileServiceMock {
// MARK: Properties
private let action: Action?
private let folder: URL
private weak var spy: FileServiceSpy?
// MARK: Initialisers
init(
currentFolder: URL,
action: Action? = nil,
spy: FileServiceSpy? = nil
) {
self.action = action
self.folder = currentFolder
self.spy = spy
}
}
// MARK: - FileServicing
extension FileServiceMock: FileServicing {
// MARK: Computed
var currentFolder: URL {
get async { folder }
}
// MARK: Functions
func createFolder(at url: URL) async throws(FileServiceError) {
switch action {
case .error(let error):
throw error
case let .createFolder(url):
try await spy?.createFolder(at: url)
default:
break
}
}
func delete(at url: URL) async throws(FileServiceError) {
switch action {
case .error(let error):
throw error
case let .delete(url):
try await spy?.delete(at: url)
default:
break
}
}
func exists(at url: URL) async throws(FileServiceError) -> Bool {
switch action {
case .error(let error):
throw error
case let .exists(url, exists):
try await spy?.exists(at: url)
return exists
default:
return false
}
}
}
// MARK: - Enumerations
extension FileServiceMock {
enum Action {
case createFolder(URL)
case delete(URL)
case error(FileServiceError)
case exists(URL, Bool)
}
}

View File

@ -1,41 +0,0 @@
import Foundation
@testable import ColibriLibrary
final class FileServiceSpy {
// MARK: Properties
private(set) var isCreateFolderCalled: Bool = false
private(set) var isDeleteCalled: Bool = false
private(set) var isExistsAtCalled: Bool = false
private(set) var urlCalled: URL?
}
// MARK: - FileServicing
extension FileServiceSpy: FileServicing {
var currentFolder: URL {
get async { .someCurrentFolder }
}
func createFolder(at url: URL) async throws(FileServiceError) {
isCreateFolderCalled = true
urlCalled = url
}
func delete(at url: URL) async throws(FileServiceError) {
isDeleteCalled = true
urlCalled = url
}
@discardableResult
func exists(at url: URL) async throws(FileServiceError) -> Bool {
isExistsAtCalled = true
urlCalled = url
return .random()
}
}