From b8c354e614aa33d2081fc07a840a088a0e1a6de0 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 27 Jan 2025 23:54:50 +0000 Subject: [PATCH] Root folder creation (#2) This PR contains the work done to create the root folder of a new project when executing the _colibri_ executable. In addition, some other work has been done: * added the `ArgumentParser` package dependency to the package; * implemented the `FileService` service in the _library_ target; * implemented the `CreateRootFolderTask` task in the _library_ target; * removed some unnecessary comments and boilerplate code; Reviewed-on: https://repo.rock-n-code.com/rock-n-code/colibri/pulls/2 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Package.swift | 3 + Sources/Executable/Colibri.swift | 11 +- Sources/Executable/Commands/Create.swift | 56 +++++++ Sources/Library/Extensions/URL+Inits.swift | 15 ++ Sources/Library/Protocols/FileServicing.swift | 25 +++ Sources/Library/Services/FileService.swift | 78 +++++++++ .../Library/Tasks/CreateRootFolderTask.swift | 48 ++++++ .../Cases/Services/FileServiceTests.swift | 154 ++++++++++++++++++ .../Tasks/CreateRootFolderTaskTests.swift | 91 +++++++++++ .../Helpers/Extensions/URL+Samples.swift | 18 ++ .../Helpers/Mocks/FileServiceMock.swift | 84 ++++++++++ .../Helpers/Spies/FileServiceSpy.swift | 41 +++++ 12 files changed, 619 insertions(+), 5 deletions(-) create mode 100644 Sources/Executable/Commands/Create.swift create mode 100644 Sources/Library/Extensions/URL+Inits.swift create mode 100644 Sources/Library/Protocols/FileServicing.swift create mode 100644 Sources/Library/Services/FileService.swift create mode 100644 Sources/Library/Tasks/CreateRootFolderTask.swift create mode 100644 Tests/Library/Cases/Services/FileServiceTests.swift create mode 100644 Tests/Library/Cases/Tasks/CreateRootFolderTaskTests.swift create mode 100644 Tests/Library/Helpers/Extensions/URL+Samples.swift create mode 100644 Tests/Library/Helpers/Mocks/FileServiceMock.swift create mode 100644 Tests/Library/Helpers/Spies/FileServiceSpy.swift diff --git a/Package.swift b/Package.swift index 5026c61..d8870c1 100644 --- a/Package.swift +++ b/Package.swift @@ -4,6 +4,9 @@ import PackageDescription let package = Package( name: "Colibri", + platforms: [ + .macOS(.v10_15) + ], products: [ .executable( name: "colibri", diff --git a/Sources/Executable/Colibri.swift b/Sources/Executable/Colibri.swift index 02690d1..8a39244 100644 --- a/Sources/Executable/Colibri.swift +++ b/Sources/Executable/Colibri.swift @@ -4,10 +4,11 @@ import ColibriLibrary @main struct Colibri: AsyncParsableCommand { - // MARK: Functions - - func run() async throws { - // ... - } + // MARK: Properties + + static let configuration = CommandConfiguration( + abstract: "The utility to manage your Hummingbird apps", + subcommands: [Create.self] + ) } diff --git a/Sources/Executable/Commands/Create.swift b/Sources/Executable/Commands/Create.swift new file mode 100644 index 0000000..a8d4373 --- /dev/null +++ b/Sources/Executable/Commands/Create.swift @@ -0,0 +1,56 @@ +import ArgumentParser +import ColibriLibrary +import Foundation + +extension Colibri { + struct Create: AsyncParsableCommand { + + // MARK: Properties + + static let configuration = CommandConfiguration( + commandName: "create-project", + abstract: "Create a new, tailored Hummingbird app", + helpNames: .shortAndLong, + aliases: ["create"] + ) + + @OptionGroup var options: Options + + // MARK: Functions + + mutating func run() async throws { + let fileService = FileService() + let createRootFolder = CreateRootFolderTask(fileService: fileService) + + 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) } + } + + } +} diff --git a/Sources/Library/Extensions/URL+Inits.swift b/Sources/Library/Extensions/URL+Inits.swift new file mode 100644 index 0000000..7c31114 --- /dev/null +++ b/Sources/Library/Extensions/URL+Inits.swift @@ -0,0 +1,15 @@ +import Foundation + +extension URL { + + // MARK: Initialisers + + init(at filePath: String) { + if #available(macOS 13.0, *) { + self = URL(filePath: filePath) + } else { + self = URL(fileURLWithPath: filePath) + } + } + +} diff --git a/Sources/Library/Protocols/FileServicing.swift b/Sources/Library/Protocols/FileServicing.swift new file mode 100644 index 0000000..5f85840 --- /dev/null +++ b/Sources/Library/Protocols/FileServicing.swift @@ -0,0 +1,25 @@ +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 +} diff --git a/Sources/Library/Services/FileService.swift b/Sources/Library/Services/FileService.swift new file mode 100644 index 0000000..3e91ad2 --- /dev/null +++ b/Sources/Library/Services/FileService.swift @@ -0,0 +1,78 @@ +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 + } + } + +} diff --git a/Sources/Library/Tasks/CreateRootFolderTask.swift b/Sources/Library/Tasks/CreateRootFolderTask.swift new file mode 100644 index 0000000..fdee7e0 --- /dev/null +++ b/Sources/Library/Tasks/CreateRootFolderTask.swift @@ -0,0 +1,48 @@ +import Foundation + +public struct CreateRootFolderTask { + + // MARK: Properties + + private let fileService: FileServicing + + // MARK: Initialisers + + public init(fileService: FileServicing) { + self.fileService = fileService + } + + // MARK: Functions + + public func callAsFunction( + name: String, + at location: URL? = nil + ) async throws -> URL { + guard !name.isEmpty else { + throw CreateRootFolderError.nameIsEmpty + } + + let rootFolder = if let location { + location + } else { + await fileService.currentFolder + } + + let newFolder = if #available(macOS 13.0, *) { + rootFolder.appending(path: name) + } else { + rootFolder.appendingPathComponent(name) + } + + try await fileService.createFolder(at: newFolder) + + return newFolder + } + +} + +// MARK: - Errors + +public enum CreateRootFolderError: Error { + case nameIsEmpty +} diff --git a/Tests/Library/Cases/Services/FileServiceTests.swift b/Tests/Library/Cases/Services/FileServiceTests.swift new file mode 100644 index 0000000..f686fe2 --- /dev/null +++ b/Tests/Library/Cases/Services/FileServiceTests.swift @@ -0,0 +1,154 @@ +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) + } + +} diff --git a/Tests/Library/Cases/Tasks/CreateRootFolderTaskTests.swift b/Tests/Library/Cases/Tasks/CreateRootFolderTaskTests.swift new file mode 100644 index 0000000..a3f6795 --- /dev/null +++ b/Tests/Library/Cases/Tasks/CreateRootFolderTaskTests.swift @@ -0,0 +1,91 @@ +import ColibriLibrary +import Foundation +import Testing + +struct CreateRootFolderTaskTests { + + // MARK: Functions tests + + @Test(arguments: [String.someProjectName], [URL.someCurrentProjectFolder, .someNewProjectFolder, .someDotProjectFolder, .someTildeProjectFolder]) + func task( + name: String, + expects folder: URL + ) async throws { + // GIVEN + let location: URL? = switch folder { + case .someNewProjectFolder: .someNewFolder + case .someDotProjectFolder: .someDotFolder + case .someTildeProjectFolder: .someTildeFolder + default: nil + } + + let fileService = FileServiceMock( + currentFolder: .someCurrentFolder, + action: .createFolder(folder) + ) + + let task = CreateRootFolderTask(fileService: fileService) + + // WHEN + let result = try await task(name: name, + at: location) + + // THEN + #expect(result == folder) + #expect(result.isFileURL == true) + } + + @Test(arguments: [String.someProjectName], [FileServiceError.urlAlreadyExists]) + func task( + name: String, + throws error: FileServiceError + ) async throws { + // GIVEN + let fileService = FileServiceMock( + currentFolder: .someCurrentFolder, + action: .error(error) + ) + + let task = CreateRootFolderTask(fileService: fileService) + + // WHEN + // THEN + await #expect(throws: error) { + try await task(name: name) + } + } + + @Test(arguments: [String.someEmptyName], [CreateRootFolderError.nameIsEmpty]) + func task( + name: String, + throws error: CreateRootFolderError + ) async throws { + // GIVEN + let fileService = FileServiceMock(currentFolder: .someCurrentFolder) + + let task = CreateRootFolderTask(fileService: fileService) + + // WHEN + // THEN + await #expect(throws: error) { + try await task(name: name) + } + } + +} + +// MARK: - String+Constants + +private extension String { + static let someEmptyName = "" + static let someProjectName = "SomeProjectName" +} + +// 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) +} diff --git a/Tests/Library/Helpers/Extensions/URL+Samples.swift b/Tests/Library/Helpers/Extensions/URL+Samples.swift new file mode 100644 index 0000000..5869bb5 --- /dev/null +++ b/Tests/Library/Helpers/Extensions/URL+Samples.swift @@ -0,0 +1,18 @@ +import Foundation + +@testable import ColibriLibrary + +extension URL { + + // MARK: Constants + + static let someCurrentFolder = URL(at: "/some/current/folder") + static let someDotFolder = URL(at: ".") + static let someExistingFolder = URL(at: "/some/existing/folder") + static let someExistingFile = URL(at: "/some/existing/file") + static let someNewFolder = URL(at: "/some/new/folder") + static let someNewFile = URL(at: "/some/new/file") + static let someRandomURL = URL(string: "http://some.random.url")! + static let someTildeFolder = URL(at: "~") + +} diff --git a/Tests/Library/Helpers/Mocks/FileServiceMock.swift b/Tests/Library/Helpers/Mocks/FileServiceMock.swift new file mode 100644 index 0000000..fa7b6d7 --- /dev/null +++ b/Tests/Library/Helpers/Mocks/FileServiceMock.swift @@ -0,0 +1,84 @@ +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) + } +} diff --git a/Tests/Library/Helpers/Spies/FileServiceSpy.swift b/Tests/Library/Helpers/Spies/FileServiceSpy.swift new file mode 100644 index 0000000..6422bc9 --- /dev/null +++ b/Tests/Library/Helpers/Spies/FileServiceSpy.swift @@ -0,0 +1,41 @@ +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() + } + +}