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:
parent
b8c354e614
commit
9be8fa4a31
@ -1,5 +1,4 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import ColibriLibrary
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct Colibri: AsyncParsableCommand {
|
struct Colibri: AsyncParsableCommand {
|
@ -1,6 +1,5 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import ColibriLibrary
|
import ColibriLibrary
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension Colibri {
|
extension Colibri {
|
||||||
struct Create: AsyncParsableCommand {
|
struct Create: AsyncParsableCommand {
|
||||||
@ -13,43 +12,27 @@ extension Colibri {
|
|||||||
helpNames: .shortAndLong,
|
helpNames: .shortAndLong,
|
||||||
aliases: ["create"]
|
aliases: ["create"]
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptionGroup var options: Options
|
@OptionGroup var options: Options
|
||||||
|
|
||||||
// MARK: Functions
|
// MARK: Functions
|
||||||
|
|
||||||
mutating func run() async throws {
|
mutating func run() async throws {
|
||||||
let fileService = FileService()
|
let fileService = FileService()
|
||||||
|
|
||||||
|
let copyFiles = CopyFilesTask(fileService: fileService)
|
||||||
|
let createFolders = CreateFoldersTask(fileService: fileService)
|
||||||
let createRootFolder = CreateRootFolderTask(fileService: fileService)
|
let createRootFolder = CreateRootFolderTask(fileService: fileService)
|
||||||
|
let initGitInFolder = InitGitInFolderTask()
|
||||||
|
|
||||||
let rootFolder = try await createRootFolder(
|
let rootFolder = try await createRootFolder(
|
||||||
name: options.name,
|
name: options.name,
|
||||||
at: options.locationURL
|
at: options.locationURL
|
||||||
)
|
)
|
||||||
|
|
||||||
print(rootFolder)
|
try await createFolders(at: rootFolder)
|
||||||
}
|
try await copyFiles(to: rootFolder)
|
||||||
|
try await initGitInFolder(at: 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) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
22
Executable/Sources/Options/CreateOptions.swift
Normal file
22
Executable/Sources/Options/CreateOptions.swift
Normal 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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
20
Library/Resources/Files/Sources/App/App
Normal file
20
Library/Resources/Files/Sources/App/App
Normal 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
20
Library/Resources/Files/Sources/App/AppOptions
Normal file
20
Library/Resources/Files/Sources/App/AppOptions
Normal 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?
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
87
Library/Resources/Files/Sources/DockerFile
Normal file
87
Library/Resources/Files/Sources/DockerFile
Normal 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"]
|
2
Library/Resources/Files/Sources/DockerIgnore
Normal file
2
Library/Resources/Files/Sources/DockerIgnore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
.build
|
||||||
|
.git
|
10
Library/Resources/Files/Sources/GitIgnore
Normal file
10
Library/Resources/Files/Sources/GitIgnore
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/.devContainer
|
||||||
|
/.swiftpm
|
||||||
|
/.vscode
|
||||||
|
/Packages
|
||||||
|
/*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
.env.*
|
||||||
|
.env
|
11
Library/Resources/Files/Sources/Library/AppArguments
Normal file
11
Library/Resources/Files/Sources/Library/AppArguments
Normal 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 }
|
||||||
|
|
||||||
|
}
|
69
Library/Resources/Files/Sources/Library/AppBuilder
Normal file
69
Library/Resources/Files/Sources/Library/AppBuilder
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
11
Library/Resources/Files/Sources/Library/Environment
Normal file
11
Library/Resources/Files/Sources/Library/Environment
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import Hummingbird
|
||||||
|
|
||||||
|
extension Environment {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
public var logLevel: String? {
|
||||||
|
self.get("LOG_LEVEL")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
9
Library/Resources/Files/Sources/Library/LoggerLevel
Normal file
9
Library/Resources/Files/Sources/Library/LoggerLevel
Normal 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
|
201
Library/Resources/Files/Sources/License
Normal file
201
Library/Resources/Files/Sources/License
Normal 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.
|
45
Library/Resources/Files/Sources/Package
Normal file
45
Library/Resources/Files/Sources/Package
Normal 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"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
10
Library/Resources/Files/Sources/Readme
Normal file
10
Library/Resources/Files/Sources/Readme
Normal 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.
|
33
Library/Resources/Files/Sources/Test/AppTests
Normal file
33
Library/Resources/Files/Sources/Test/AppTests
Normal 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: "")
|
||||||
|
}
|
12
Library/Resources/Files/Sources/Test/TestArguments
Normal file
12
Library/Resources/Files/Sources/Test/TestArguments
Normal 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
|
||||||
|
|
||||||
|
}
|
74
Library/Sources/Internal/Enumerations/File.swift
Normal file
74
Library/Sources/Internal/Enumerations/File.swift
Normal 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 {}
|
48
Library/Sources/Internal/Enumerations/Folder.swift
Normal file
48
Library/Sources/Internal/Enumerations/Folder.swift
Normal 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 }
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Bundleable
|
||||||
|
|
||||||
|
extension Bundle: Bundleable {}
|
72
Library/Sources/Internal/Extensions/Pipe+Properties.swift
Normal file
72
Library/Sources/Internal/Extensions/Pipe+Properties.swift
Normal 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Processable
|
||||||
|
|
||||||
|
extension Process: Processable {}
|
35
Library/Sources/Internal/Extensions/URL+Extensions.swift
Normal file
35
Library/Sources/Internal/Extensions/URL+Extensions.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
17
Library/Sources/Internal/Protocols/Processable.swift
Normal file
17
Library/Sources/Internal/Protocols/Processable.swift
Normal 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
|
||||||
|
|
||||||
|
}
|
75
Library/Sources/Internal/Tasks/RunProcessTask.swift
Normal file
75
Library/Sources/Internal/Tasks/RunProcessTask.swift
Normal 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
|
||||||
|
}
|
9
Library/Sources/Public/Protocols/Bundleable.swift
Normal file
9
Library/Sources/Public/Protocols/Bundleable.swift
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol Bundleable {
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
|
||||||
|
func url(forResource name: String?, withExtension ext: String?, subdirectory subpath: String?) -> URL?
|
||||||
|
|
||||||
|
}
|
28
Library/Sources/Public/Protocols/FileServicing.swift
Normal file
28
Library/Sources/Public/Protocols/FileServicing.swift
Normal 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
|
||||||
|
}
|
79
Library/Sources/Public/Services/FileService.swift
Normal file
79
Library/Sources/Public/Services/FileService.swift
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
39
Library/Sources/Public/Tasks/CopyFilesTask.swift
Normal file
39
Library/Sources/Public/Tasks/CopyFilesTask.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
25
Library/Sources/Public/Tasks/CreateFoldersTask.swift
Normal file
25
Library/Sources/Public/Tasks/CreateFoldersTask.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -14,10 +14,7 @@ public struct CreateRootFolderTask {
|
|||||||
|
|
||||||
// MARK: Functions
|
// MARK: Functions
|
||||||
|
|
||||||
public func callAsFunction(
|
public func callAsFunction(name: String, at location: URL? = nil) async throws -> URL {
|
||||||
name: String,
|
|
||||||
at location: URL? = nil
|
|
||||||
) async throws -> URL {
|
|
||||||
guard !name.isEmpty else {
|
guard !name.isEmpty else {
|
||||||
throw CreateRootFolderError.nameIsEmpty
|
throw CreateRootFolderError.nameIsEmpty
|
||||||
}
|
}
|
||||||
@ -27,12 +24,8 @@ public struct CreateRootFolderTask {
|
|||||||
} else {
|
} else {
|
||||||
await fileService.currentFolder
|
await fileService.currentFolder
|
||||||
}
|
}
|
||||||
|
|
||||||
let newFolder = if #available(macOS 13.0, *) {
|
let newFolder = rootFolder.appendingPath(name)
|
||||||
rootFolder.appending(path: name)
|
|
||||||
} else {
|
|
||||||
rootFolder.appendingPathComponent(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
try await fileService.createFolder(at: newFolder)
|
try await fileService.createFolder(at: newFolder)
|
||||||
|
|
24
Library/Sources/Public/Tasks/InitGitInFolderTask.swift
Normal file
24
Library/Sources/Public/Tasks/InitGitInFolderTask.swift
Normal 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"])
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -8,42 +8,35 @@ let package = Package(
|
|||||||
.macOS(.v10_15)
|
.macOS(.v10_15)
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.executable(
|
.executable(name: "colibri", targets: ["Colibri"]),
|
||||||
name: "colibri",
|
.library(name: "ColibriLibrary", targets: ["ColibriLibrary"])
|
||||||
targets: ["Colibri"]
|
|
||||||
),
|
|
||||||
.library(
|
|
||||||
name: "ColibriLibrary",
|
|
||||||
targets: ["ColibriLibrary"]
|
|
||||||
)
|
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(
|
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0")
|
||||||
url: "https://github.com/apple/swift-argument-parser",
|
|
||||||
from: "1.0.0"
|
|
||||||
)
|
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "Colibri",
|
name: "Colibri",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
name: "ArgumentParser",
|
|
||||||
package: "swift-argument-parser"
|
|
||||||
),
|
|
||||||
.target(name: "ColibriLibrary")
|
.target(name: "ColibriLibrary")
|
||||||
],
|
],
|
||||||
path: "Sources/Executable"
|
path: "Executable"
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "ColibriLibrary",
|
name: "ColibriLibrary",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
path: "Sources/Library"
|
path: "Library",
|
||||||
|
resources: [
|
||||||
|
.copy("Resources")
|
||||||
|
]
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "ColibriTests",
|
name: "ColibriTests",
|
||||||
dependencies: ["ColibriLibrary"],
|
dependencies: [
|
||||||
path: "Tests/Library"
|
.target(name: "ColibriLibrary")
|
||||||
|
],
|
||||||
|
path: "Test"
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
@ -1 +0,0 @@
|
|||||||
import Foundation
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
125
Test/Sources/Cases/Internal/Enumerations/FileTests.swift
Normal file
125
Test/Sources/Cases/Internal/Enumerations/FileTests.swift
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
35
Test/Sources/Cases/Internal/Enumerations/FolderTests.swift
Normal file
35
Test/Sources/Cases/Internal/Enumerations/FolderTests.swift
Normal 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/"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
68
Test/Sources/Cases/Internal/Tasks/RunProcessTaskTests.swift
Normal file
68
Test/Sources/Cases/Internal/Tasks/RunProcessTaskTests.swift
Normal 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")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
181
Test/Sources/Cases/Public/Services/FileServiceTests.swift
Normal file
181
Test/Sources/Cases/Public/Services/FileServiceTests.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
80
Test/Sources/Cases/Public/Tasks/CopyFilesTaskTests.swift
Normal file
80
Test/Sources/Cases/Public/Tasks/CopyFilesTaskTests.swift
Normal 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
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
49
Test/Sources/Cases/Public/Tasks/CreateFoldersTaskTests.swift
Normal file
49
Test/Sources/Cases/Public/Tasks/CreateFoldersTaskTests.swift
Normal 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
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,7 +1,8 @@
|
|||||||
import ColibriLibrary
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
|
@testable import ColibriLibrary
|
||||||
|
|
||||||
struct CreateRootFolderTaskTests {
|
struct CreateRootFolderTaskTests {
|
||||||
|
|
||||||
// MARK: Functions tests
|
// MARK: Functions tests
|
||||||
@ -19,12 +20,10 @@ struct CreateRootFolderTaskTests {
|
|||||||
default: nil
|
default: nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileService = FileServiceMock(
|
let task = CreateRootFolderTask(fileService: FileServiceMock(
|
||||||
currentFolder: .someCurrentFolder,
|
currentFolder: .someCurrentFolder,
|
||||||
action: .createFolder(folder)
|
action: .createFolder(folder)
|
||||||
)
|
))
|
||||||
|
|
||||||
let task = CreateRootFolderTask(fileService: fileService)
|
|
||||||
|
|
||||||
// WHEN
|
// WHEN
|
||||||
let result = try await task(name: name,
|
let result = try await task(name: name,
|
||||||
@ -35,18 +34,16 @@ struct CreateRootFolderTaskTests {
|
|||||||
#expect(result.isFileURL == true)
|
#expect(result.isFileURL == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test(arguments: [String.someProjectName], [FileServiceError.urlAlreadyExists])
|
@Test(arguments: [String.someProjectName], [FileServiceError.itemAlreadyExists])
|
||||||
func task(
|
func task(
|
||||||
name: String,
|
name: String,
|
||||||
throws error: FileServiceError
|
throws error: FileServiceError
|
||||||
) async throws {
|
) async throws {
|
||||||
// GIVEN
|
// GIVEN
|
||||||
let fileService = FileServiceMock(
|
let task = CreateRootFolderTask(fileService: FileServiceMock(
|
||||||
currentFolder: .someCurrentFolder,
|
currentFolder: .someCurrentFolder,
|
||||||
action: .error(error)
|
action: .error(error)
|
||||||
)
|
))
|
||||||
|
|
||||||
let task = CreateRootFolderTask(fileService: fileService)
|
|
||||||
|
|
||||||
// WHEN
|
// WHEN
|
||||||
// THEN
|
// THEN
|
||||||
@ -61,9 +58,9 @@ struct CreateRootFolderTaskTests {
|
|||||||
throws error: CreateRootFolderError
|
throws error: CreateRootFolderError
|
||||||
) async throws {
|
) async throws {
|
||||||
// GIVEN
|
// GIVEN
|
||||||
let fileService = FileServiceMock(currentFolder: .someCurrentFolder)
|
let task = CreateRootFolderTask(fileService: FileServiceMock(
|
||||||
|
currentFolder: .someCurrentFolder
|
||||||
let task = CreateRootFolderTask(fileService: fileService)
|
))
|
||||||
|
|
||||||
// WHEN
|
// WHEN
|
||||||
// THEN
|
// THEN
|
||||||
@ -84,8 +81,8 @@ private extension String {
|
|||||||
// MARK: - URL+Constants
|
// MARK: - URL+Constants
|
||||||
|
|
||||||
private extension URL {
|
private extension URL {
|
||||||
static let someCurrentProjectFolder = URL.someCurrentFolder.appending(component: String.someProjectName)
|
static let someCurrentProjectFolder = URL.someCurrentFolder.appendingPath(.someProjectName)
|
||||||
static let someDotProjectFolder = URL.someDotFolder.appending(component: String.someProjectName)
|
static let someDotProjectFolder = URL.someDotFolder.appendingPath(.someProjectName)
|
||||||
static let someNewProjectFolder = URL.someNewFolder.appending(component: String.someProjectName)
|
static let someNewProjectFolder = URL.someNewFolder.appendingPath(.someProjectName)
|
||||||
static let someTildeProjectFolder = URL.someTildeFolder.appending(component: String.someProjectName)
|
static let someTildeProjectFolder = URL.someTildeFolder.appendingPath(.someProjectName)
|
||||||
}
|
}
|
135
Test/Sources/Helpers/Mocks/FileServiceMock.swift
Normal file
135
Test/Sources/Helpers/Mocks/FileServiceMock.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
51
Test/Sources/Helpers/Spies/FileServiceSpy.swift
Normal file
51
Test/Sources/Helpers/Spies/FileServiceSpy.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
import Testing
|
|
||||||
|
|
||||||
struct ColibriLibraryTests {}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
Loading…
x
Reference in New Issue
Block a user