From 8c956bcd2964e8ea9b17383ec39763a20bc4f6d2 Mon Sep 17 00:00:00 2001 From: John Sundell Date: Sat, 7 Sep 2019 14:07:00 +0200 Subject: [PATCH] Files 4.0 (#87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files 4.0 This change includes a brand new implementation for Files, that keeps most of the public API intact, while modernizing the underlying implementation. Under the hood, Files now uses value types and protocols, rather than class inheritance - which improves the type safety of the API, and streamlines the underlying code. Error handling is also improved to include more underlying info, and the docs have been reworked to be much more thorough. The API has also been fine-tuned and modernized, dropping the `FileSystem` class in favor of more “Swifty” APIs on `Folder`, introducing more options for creating new files and folders, and making it possible to easily change properties on file system sequences. A list of all changes will be posted as part of this version’s release notes. --- .gitignore | 1 + README.md | 41 +- Sources/Files.swift | 1766 +++++++++++++---------------- Tests/FilesTests/FilesTests.swift | 293 ++--- 4 files changed, 1005 insertions(+), 1096 deletions(-) diff --git a/.gitignore b/.gitignore index 0d4a47c..d32735f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ playground.xcworkspace # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Packages/ .build/ +.swiftpm/ # CocoaPods # diff --git a/README.md b/README.md index a9f445c..3394b7f 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,6 @@ Carthage - - Marathon - Swift Package Manager @@ -35,6 +32,7 @@ Welcome to **Files**, a compact library that provides a nicer way to handle *fil ## Examples Iterate over the files contained in a folder: + ```swift for file in try Folder(path: "MyFolder").files { print(file.name) @@ -42,6 +40,7 @@ for file in try Folder(path: "MyFolder").files { ``` Rename all files contained in a folder: + ```swift try Folder(path: "MyFolder").files.enumerated().forEach { (index, file) in try file.rename(to: file.nameWithoutExtension + "\(index)") @@ -49,13 +48,15 @@ try Folder(path: "MyFolder").files.enumerated().forEach { (index, file) in ``` Recursively iterate over all folders in a tree: + ```swift -Folder.home.makeSubfolderSequence(recursive: true).forEach { folder in +Folder.home.subfolders.recursive.forEach { folder in print("Name : \(folder.name), parent: \(folder.parent)") } ``` Create, write and delete files and folders: + ```swift let folder = try Folder(path: "/users/john/folder") let file = try folder.createFile(named: "file.json") @@ -65,6 +66,7 @@ try folder.delete() ``` Move all files in a folder to another: + ```swift let originFolder = try Folder(path: "/users/john/folderA") let targetFolder = try Folder(path: "/users/john/folderB") @@ -72,41 +74,40 @@ try originFolder.files.move(to: targetFolder) ``` Easy access to system folders: + ```swift Folder.current +Folder.root +Folder.library Folder.temporary Folder.home +Folder.documents ``` -## Usage - -Files can be easily used in either a Swift script, command line tool or in an app for iOS, macOS, tvOS or Linux. - -### In a script +## Installation -- Install [Marathon](https://github.com/johnsundell/marathon). -- Add Files using `$ marathon add https://github.com/johnsundell/files.git`. -- Run your script using `$ marathon run `. +Files can be easily used in either a Swift script, a command line tool, or in an app for iOS, macOS, tvOS or Linux. -### In a command line tool +### Using the Swift Package Manager (preferred) -- Drag the file `Files.swift` into your command line tool's Xcode project. +To install Files for use in a Swift Package Manager-powered tool, script or server-side application, add Files as a dependency to your `Package.swift` file. For more information, please see the [Swift Package Manager documentation](https://github.com/apple/swift-package-manager/tree/master/Documentation). -### In an application +```swift +.package(url: "https://github.com/JohnSundell/Files", from: "4.0.0") +``` -Either +### Using CocoaPods or Carthage -- Drag the file `Files.swift` into your application's Xcode project. +Please refer to [CocoaPods’](https://cocoapods.org) or [Carthage’s](https://github.com/Carthage/Carthage) own documentation for instructions on how to add dependencies using those tools. -or +### As a file -- Use CocoaPods, Carthage or the Swift Package manager to include Files as a dependency in your project. +Since all of Files is implemented within a single file, you can easily use it in any project by simply dragging the file `Files.swift` into your Xcode project. ## Backstory So, why was this made? As I've migrated most of my build tools and other scripts from languages like Bash, Ruby and Python to Swift, I've found myself lacking an easy way to deal with the file system. Sure, `FileManager` has a quite nice API, but it can be quite cumbersome to use because of its string-based nature, which makes simple scripts that move or rename files quickly become quite complex. - So, I made **Files**, to enable me to quickly handle files and folders, in an expressive way. And, since I love open source, I thought - why not package it up and share it with the community? :) ## Questions or feedback? diff --git a/Sources/Files.swift b/Sources/Files.swift index 5968e6a..d8e0368 100644 --- a/Sources/Files.swift +++ b/Sources/Files.swift @@ -1,7 +1,7 @@ /** * Files * - * Copyright (c) 2017 John Sundell. Licensed under the MIT license, as follows: + * Copyright (c) 2017-2019 John Sundell. Licensed under the MIT license, as follows: * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -24,1095 +24,963 @@ import Foundation -// MARK: - Public API +// MARK: - Locations -/** - * Class that represents a file system - * - * You only have to interact with this class in case you want to get a reference - * to a system folder (like the temporary cache folder, or the user's home folder). - * - * To open other files & folders, use the `File` and `Folder` class respectively. - */ -public class FileSystem { - fileprivate let fileManager: FileManager - - /** - * Class that represents an item that's stored by a file system - * - * This is an abstract base class, that has two publically initializable concrete - * implementations, `File` and `Folder`. You can use the APIs available on this class - * to perform operations that are supported by both files & folders. - */ - public class Item: Equatable, CustomStringConvertible { - /// Errror type used for invalid paths for files or folders - public enum PathError: Error, Equatable, CustomStringConvertible { - /// Thrown when an empty path was given when initializing a file - case empty - /// Thrown when an item of the expected type wasn't found for a given path (contains the path) - case invalid(String) - - /// A string describing the error - public var description: String { - switch self { - case .empty: - return "Empty path given" - case .invalid(let path): - return "Invalid path given: \(path)" - } - } - } - - /// Error type used for failed operations run on files or folders - public enum OperationError: Error, Equatable, CustomStringConvertible { - /// Thrown when a file or folder couldn't be renamed (contains the item) - case renameFailed(Item) - /// Thrown when a file or folder couldn't be moved (contains the item) - case moveFailed(Item) - /// Thrown when a file or folder couldn't be copied (contains the item) - case copyFailed(Item) - /// Thrown when a file or folder couldn't be deleted (contains the item) - case deleteFailed(Item) - - /// A string describing the error - public var description: String { - switch self { - case .renameFailed(let item): - return "Failed to rename item: \(item)" - case .moveFailed(let item): - return "Failed to move item: \(item)" - case .copyFailed(let item): - return "Failed to copy item: \(item)" - case .deleteFailed(let item): - return "Failed to delete item: \(item)" - } - } - } - - /// Operator used to compare two instances for equality - public static func ==(lhs: Item, rhs: Item) -> Bool { - guard lhs.kind == rhs.kind else { - return false - } - - return lhs.path == rhs.path +/// Enum describing various kinds of locations that can be found on a file system. +public enum LocationKind { + /// A file can be found at the location. + case file + /// A folder can be found at the location. + case folder +} + +/// Protocol adopted by types that represent locations on a file system. +public protocol Location: Equatable, CustomStringConvertible { + /// The kind of location that is being represented (see `LocationKind`). + static var kind: LocationKind { get } + /// The underlying storage for the item at the represented location. + /// You don't interact with this object as part of the public API. + var storage: Storage { get } + /// Initialize an instance of this location with its underlying storage. + /// You don't call this initializer as part of the public API, instead + /// use `init(path:)` on either `File` or `Folder`. + init(storage: Storage) +} + +public extension Location { + static func ==(lhs: Self, rhs: Self) -> Bool { + return lhs.storage.path == rhs.storage.path + } + + var description: String { + let typeName = String(describing: type(of: self)) + return "\(typeName)(name: \(name), path: \(path))" + } + + /// The path of this location, relative to the root of the file system. + var path: String { + return storage.path + } + + /// A URL representation of the location's `path`. + var url: URL { + return URL(fileURLWithPath: path) + } + + /// The name of the location, including any `extension`. + var name: String { + return url.pathComponents.last! + } + + /// The name of the location, excluding its `extension`. + var nameExcludingExtension: String { + return name.split(separator: ".").dropLast().joined() + } + + /// The file extension of the item at the location. + var `extension`: String? { + let components = name.split(separator: ".") + guard components.count > 1 else { return nil } + return String(components.last!) + } + + /// The parent folder that this location is contained within. + var parent: Folder? { + return storage.makeParentPath(for: path).flatMap { + try? Folder(path: $0) } - - /// The path of the item, relative to the root of the file system - public private(set) var path: String - - /// The name of the item (including any extension) - public private(set) var name: String - - /// The name of the item (excluding any extension) - public var nameExcludingExtension: String { - guard let `extension` = `extension` else { - return name - } + } + + /// The date when the item at this location was created. + /// Only returns `nil` in case the item has now been deleted. + var creationDate: Date? { + return storage.attributes[.creationDate] as? Date + } + + /// The date when the item at this location was last modified. + /// Only returns `nil` in case the item has now been deleted. + var modificationDate: Date? { + return storage.attributes[.modificationDate] as? Date + } + + /// Initialize an instance of an existing location at a given path. + /// - parameter path: The absolute path of the location. + /// - throws: `LocationError` if the item couldn't be found. + init(path: String) throws { + try self.init(storage: Storage( + path: path, + fileManager: .default + )) + } - let endIndex = name.index(name.endIndex, offsetBy: -`extension`.count - 1) - return String(name[.. String { + guard path.hasPrefix(folder.path) else { + return path } - - /// Any extension that the item has - public var `extension`: String? { - let components = name.components(separatedBy: ".") - - guard components.count > 1 else { - return nil - } - - return components.last + + let index = path.index(path.startIndex, offsetBy: folder.path.count) + return String(path[index...]).removingSuffix("/") + } + + /// Rename this location, keeping its existing `extension` by default. + /// - parameter newName: The new name to give the location. + /// - parameter keepExtension: Whether the location's `extension` should + /// remain unmodified (default: `true`). + /// - throws: `LocationError` if the item couldn't be renamed. + func rename(to newName: String, keepExtension: Bool = true) throws { + guard let parent = parent else { + throw LocationError(path: path, reason: .cannotRenameRoot) } - /// The date when the item was last modified - public private(set) lazy var modificationDate: Date = self.loadModificationDate() + var newName = newName - /// The folder that the item is contained in, or `nil` if this item is the root folder of the file system - public var parent: Folder? { - return fileManager.parentPath(for: path).flatMap { parentPath in - return try? Folder(path: parentPath, using: fileManager) + if keepExtension { + `extension`.map { + newName = newName.appendingSuffixIfNeeded(".\($0)") } } - - /// A string describing the item - public var description: String { - return "\(kind)(name: \(name), path: \(path))" - } - - fileprivate let kind: Kind - fileprivate let fileManager: FileManager - - fileprivate init(path: String, kind: Kind, using fileManager: FileManager) throws { + + try storage.move( + to: parent.path + newName, + errorReasonProvider: LocationErrorReason.renameFailed + ) + } + + /// Move this location to a new parent folder + /// - parameter newParent: The folder to move this item to. + /// - throws: `LocationError` if the location couldn't be moved. + func move(to newParent: Folder) throws { + try storage.move( + to: newParent.path + name, + errorReasonProvider: LocationErrorReason.moveFailed + ) + } + + /// Copy the contents of this location to a given folder + /// - parameter newParent: The folder to copy this item to. + /// - throws: `LocationError` if the location couldn't be copied. + func copy(to folder: Folder) throws { + try storage.copy(to: folder.path + name) + } + + /// Delete this location. It will be permanently deleted. Use with caution. + /// - throws: `LocationError` if the item couldn't be deleted. + func delete() throws { + try storage.delete() + } + + /// Assign a new `FileManager` to manage this location. Typically only used + /// for testing, or when building custom file systems. Returns a new instance, + /// doensn't modify the instance this is called on. + /// - parameter manager: The new file manager that should manage this location. + /// - throws: `LocationError` if the change couldn't be completed. + func managedBy(_ manager: FileManager) throws -> Self { + return try Self(storage: Storage( + path: path, + fileManager: manager + )) + } +} + +// MARK: - Storage + +/// Type used to store information about a given file system location. You don't +/// interact with this type as part of the public API, instead you use the APIs +/// exposed by `Location`, `File`, and `Folder`. +public final class Storage { + fileprivate private(set) var path: String + private let fileManager: FileManager + + fileprivate init(path: String, fileManager: FileManager) throws { + self.path = path + self.fileManager = fileManager + try validatePath() + } + + private func validatePath() throws { + switch LocationType.kind { + case .file: guard !path.isEmpty else { - throw PathError.empty - } - - let path = try fileManager.absolutePath(for: path) - - guard fileManager.itemKind(atPath: path) == kind else { - throw PathError.invalid(path) - } - - self.path = path - self.fileManager = fileManager - self.kind = kind - - let pathComponents = path.pathComponents - - switch kind { - case .file: - self.name = pathComponents.last! - case .folder: - self.name = pathComponents[pathComponents.count - 2] + throw LocationError(path: path, reason: .emptyFilePath) } + case .folder: + if path.isEmpty { path = fileManager.currentDirectoryPath } + if !path.hasSuffix("/") { path += "/" } } - /** - * Return this item's path relative to a given parent folder - * - * - parameter folder: The parent folder to return a relative path to - * - * - returns: Either a relative path, if the passed folder is indeed - * a parent (even if it's not a direct one) for this item. Otherwise - * the item's full path is returned. - */ - public func path(relativeTo folder: Folder) -> String { - guard path.hasPrefix(folder.path) else { - return path - } + if path.hasPrefix("~") { + let homePath = ProcessInfo.processInfo.environment["HOME"]! + path = homePath + path.dropFirst() + } - let index = path.index(path.startIndex, offsetBy: folder.path.count) - var subpath = path[index...] + while let parentReferenceRange = path.range(of: "../") { + let folderPath = String(path[.. File { - let path = try fileManager.absolutePath(for: path) - - guard let parentPath = fileManager.parentPath(for: path) else { - throw File.Error.writeFailed - } + func makeParentPath(for path: String) -> String? { + guard path != "/" else { return nil } + let url = URL(fileURLWithPath: path) + let components = url.pathComponents.dropFirst().dropLast() + return "/" + components.joined(separator: "/") + "/" + } + func move(to newPath: String, + errorReasonProvider: (Error) -> LocationErrorReason) throws { do { - let index = path.index(path.startIndex, offsetBy: parentPath.count + 1) - let name = String(path[index...]) - return try createFolder(at: parentPath).createFile(named: name, contents: contents) + try fileManager.moveItem(atPath: path, toPath: newPath) + + switch LocationType.kind { + case .file: + path = newPath + case .folder: + path = newPath.appendingSuffixIfNeeded("/") + } } catch { - throw File.Error.writeFailed + throw LocationError(path: path, reason: errorReasonProvider(error)) } } - /** - * Either return an existing file, or create a new one, at a given path. - * - * - parameter path: The path for which a file should either be returned or created at. If the folder - * is missing, any intermediate parent folders will also be created. - * - * - throws: `File.Error.writeFailed` - * - * - returns: The file that was either created or found. - */ - @discardableResult public func createFileIfNeeded(at path: String, contents: Data = Data()) throws -> File { - if let existingFile = try? File(path: path, using: fileManager) { - return existingFile + func copy(to newPath: String) throws { + do { + try fileManager.copyItem(atPath: path, toPath: newPath) + } catch { + throw LocationError(path: path, reason: .copyFailed(error)) } - - return try createFile(at: path, contents: contents) } - /** - * Create a new folder at a given path - * - * - parameter path: The path at which a folder should be created. If the path is missing intermediate - * parent folders, those will be created as well. - * - * - throws: `Folder.Error.creatingFolderFailed` - * - * - returns: The folder that was created - */ - @discardableResult public func createFolder(at path: String) throws -> Folder { + func delete() throws { do { - let path = try fileManager.absolutePath(for: path) - try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil) - return try Folder(path: path, using: fileManager) + try fileManager.removeItem(atPath: path) } catch { - throw Folder.Error.creatingFolderFailed + throw LocationError(path: path, reason: .deleteFailed(error)) } } +} - /** - * Either return an existing folder, or create a new one, at a given path - * - * - parameter path: The path for which a folder should either be returned or created at. If the folder - * is missing, any intermediate parent folders will also be created. - * - * - throws: `Folder.Error.creatingFolderFailed` - */ - @discardableResult public func createFolderIfNeeded(at path: String) throws -> Folder { - if let existingFolder = try? Folder(path: path, using: fileManager) { - return existingFolder - } +private extension Storage where LocationType == Folder { + func makeChildSequence() -> Folder.ChildSequence { + return Folder.ChildSequence( + folder: Folder(storage: self), + fileManager: fileManager, + isRecursive: false, + includeHidden: false + ) + } - return try createFolder(at: path) + func subfolder(at folderPath: String) throws -> Folder { + let folderPath = path + folderPath.removingPrefix("/") + let storage = try Storage(path: folderPath, fileManager: fileManager) + return Folder(storage: storage) } -} -/** - * Class representing a file that's stored by a file system - * - * You initialize this class with a path, or by asking a folder to return a file for a given name - */ -public final class File: FileSystem.Item, FileSystemIterable { - /// Error type specific to file-related operations - public enum Error: Swift.Error, CustomStringConvertible { - /// Thrown when a file couldn't be written to - case writeFailed - /// Thrown when a file couldn't be read, either because it was malformed or because it has been deleted - case readFailed - - /// A string describing the error - public var description: String { - switch self { - case .writeFailed: - return "Failed to write to file" - case .readFailed: - return "Failed to read file" - } - } + func file(at filePath: String) throws -> File { + let filePath = path + filePath.removingPrefix("/") + let storage = try Storage(path: filePath, fileManager: fileManager) + return File(storage: storage) } - - /** - * Initialize an instance of this class with a path pointing to a file - * - * - parameter path: The path of the file to create a representation of - * - parameter fileManager: Optionally give a custom file manager to use to look up the file - * - * - throws: `FileSystemItem.Error` in case an empty path was given, or if the path given doesn't - * point to a readable file. - */ - public init(path: String, using fileManager: FileManager = .default) throws { - try super.init(path: path, kind: .file, using: fileManager) - } - - /** - * Read the data contained within this file - * - * - throws: `File.Error.readFailed` if the file's data couldn't be read - */ - public func read() throws -> Data { + + func createSubfolder(at folderPath: String) throws -> Folder { + let folderPath = path + folderPath.removingPrefix("/") + + guard folderPath != path else { + throw WriteError(path: folderPath, reason: .emptyPath) + } + do { - return try Data(contentsOf: URL(fileURLWithPath: path)) + try fileManager.createDirectory( + atPath: folderPath, + withIntermediateDirectories: true + ) + + let storage = try Storage(path: folderPath, fileManager: fileManager) + return Folder(storage: storage) } catch { - throw Error.readFailed + throw WriteError(path: folderPath, reason: .folderCreationFailed(error)) } } - /** - * Read the data contained within this file, and convert it to a string - * - * - throws: `File.Error.readFailed` if the file's data couldn't be read as a string - */ - public func readAsString(encoding: String.Encoding = .utf8) throws -> String { - guard let string = try String(data: read(), encoding: encoding) else { - throw Error.readFailed + func createFile(at filePath: String, contents: Data?) throws -> File { + let filePath = path + filePath.removingPrefix("/") + + guard let parentPath = makeParentPath(for: filePath) else { + throw WriteError(path: filePath, reason: .emptyPath) } - return string - } + if parentPath != path { + do { + try fileManager.createDirectory( + atPath: parentPath, + withIntermediateDirectories: true + ) + } catch { + throw WriteError(path: parentPath, reason: .folderCreationFailed(error)) + } + } - /** - * Read the data contained within this file, and convert it to an int - * - * - throws: `File.Error.readFailed` if the file's data couldn't be read as an int - */ - public func readAsInt() throws -> Int { - guard let int = try Int(readAsString()) else { - throw Error.readFailed + guard fileManager.createFile(atPath: filePath, contents: contents), + let storage = try? Storage(path: filePath, fileManager: fileManager) else { + throw WriteError(path: filePath, reason: .fileCreationFailed) } - return int + return File(storage: storage) + } +} + +// MARK: - Files + +/// Type that represents a file on disk. You can either reference an existing +/// file by initializing an instance with a `path`, or you can create new files +/// using the various `createFile...` APIs available on `Folder`. +public struct File: Location { + public let storage: Storage + + public init(storage: Storage) { + self.storage = storage + } +} + +public extension File { + static var kind: LocationKind { + return .file } - - /** - * Write data to the file, replacing its current content - * - * - parameter data: The data to write to the file - * - * - throws: `File.Error.writeFailed` if the file couldn't be written to - */ - public func write(data: Data) throws { + + /// Write a new set of binary data into the file, replacing its current contents. + /// - parameter data: The binary data to write. + /// - throws: `WriteError` in case the operation couldn't be completed. + func write(_ data: Data) throws { do { - try data.write(to: URL(fileURLWithPath: path)) + try data.write(to: url) } catch { - throw Error.writeFailed + throw WriteError(path: path, reason: .writeFailed(error)) } } - - /** - * Write a string to the file, replacing its current content - * - * - parameter string: The string to write to the file - * - parameter encoding: Optionally give which encoding that the string should be encoded in (defaults to UTF-8) - * - * - throws: `File.Error.writeFailed` if the string couldn't be encoded, or written to the file - */ - public func write(string: String, encoding: String.Encoding = .utf8) throws { + + /// Write a new string into the file, replacing its current contents. + /// - parameter string: The string to write. + /// - parameter encoding: The encoding of the string (default: `UTF8`). + /// - throws: `WriteError` in case the operation couldn't be completed. + func write(_ string: String, encoding: String.Encoding = .utf8) throws { guard let data = string.data(using: encoding) else { - throw Error.writeFailed + throw WriteError(path: path, reason: .stringEncodingFailed(string)) } - - try write(data: data) - } - - /** - * Append data to the end of the file - * - * - parameter data: The data to append to the file - * - * - throws: `File.Error.writeFailed` if the file couldn't be written to - */ - public func append(data: Data) throws { + + return try write(data) + } + + /// Append a set of binary data to the file's existing contents. + /// - parameter data: The binary data to append. + /// - throws: `WriteError` in case the operation couldn't be completed. + func append(_ data: Data) throws { do { - let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: path)) + let handle = try FileHandle(forWritingTo: url) handle.seekToEndOfFile() handle.write(data) handle.closeFile() } catch { - throw Error.writeFailed + throw WriteError(path: path, reason: .writeFailed(error)) } } - /** - * Append a string to the end of the file - * - * - parameter string: The string to append to the file - * - parameter encoding: Optionally give which encoding that the string should be encoded in (defaults to UTF-8) - * - * - throws: `File.Error.writeFailed` if the string couldn't be encoded, or written to the file - */ - public func append(string: String, encoding: String.Encoding = .utf8) throws { + /// Append a string to the file's existing contents. + /// - parameter string: The string to append. + /// - parameter encoding: The encoding of the string (default: `UTF8`). + /// - throws: `WriteError` in case the operation couldn't be completed. + func append(_ string: String, encoding: String.Encoding = .utf8) throws { guard let data = string.data(using: encoding) else { - throw Error.writeFailed + throw WriteError(path: path, reason: .stringEncodingFailed(string)) } - try append(data: data) - } - - /** - * Copy this file to a new folder - * - * - parameter folder: The folder that the file should be copy to - * - * - throws: `FileSystem.Item.OperationError.copyFailed` if the file couldn't be copied - */ - @discardableResult public func copy(to folder: Folder) throws -> File { - let newPath = folder.path + name - - do { - try fileManager.copyItem(atPath: path, toPath: newPath) - return try File(path: newPath) - } catch { - throw OperationError.copyFailed(self) - } + return try append(data) } -} -/** - * Class representing a folder that's stored by a file system - * - * You initialize this class with a path, or by asking a folder to return a subfolder for a given name - */ -public final class Folder: FileSystem.Item, FileSystemIterable { - /// Error type specific to folder-related operations - public enum Error: Swift.Error, CustomStringConvertible { - /// Thrown when a folder couldn't be created - case creatingFolderFailed - - /// A string describing the error - public var description: String { - switch self { - case .creatingFolderFailed: - return "Failed to create folder" - } - } + /// Read the contents of the file as binary data. + /// - throws: `ReadError` if the file couldn't be read. + func read() throws -> Data { + do { return try Data(contentsOf: url) } + catch { throw ReadError(path: path, reason: .readFailed(error)) } } - - /// The sequence of files that are contained within this folder (non-recursive) - public var files: FileSystemSequence { - return makeFileSequence() - } - - /// The sequence of folders that are subfolers of this folder (non-recursive) - public var subfolders: FileSystemSequence { - return makeSubfolderSequence() - } - - /// A reference to the folder that is the current working directory - public static var current: Folder { - return FileSystem(using: .default).currentFolder - } - - /// A reference to the current user's home folder - public static var home: Folder { - return FileSystem(using: .default).homeFolder - } - - /// A reference to the temporary folder used by this file system - public static var temporary: Folder { - return FileSystem(using: .default).temporaryFolder - } - - /** - * Initialize an instance of this class with a path pointing to a folder - * - * - parameter path: The path of the folder to create a representation of - * - parameter fileManager: Optionally give a custom file manager to use to look up the folder - * - * - throws: `FileSystemItem.Error` in case an empty path was given, or if the path given doesn't - * point to a readable folder. - */ - public init(path: String, using fileManager: FileManager = .default) throws { - var path = path - - if path.isEmpty { - path = fileManager.currentDirectoryPath - } - if !path.hasSuffix("/") { - path += "/" - } - - try super.init(path: path, kind: .folder, using: fileManager) - } - - /** - * Return a file with a given name that is contained in this folder - * - * - parameter fileName: The name of the file to return - * - * - throws: `File.PathError.invalid` if the file couldn't be found - */ - public func file(named fileName: String) throws -> File { - return try File(path: path + fileName, using: fileManager) - } - - /** - * Return a file at a given path that is contained in this folder's tree - * - * - parameter filePath: The subpath of the file to return. Relative to this folder. - * - * - throws: `File.PathError.invalid` if the file couldn't be found - */ - public func file(atPath filePath: String) throws -> File { - return try File(path: path + filePath, using: fileManager) - } - - /** - * Return whether this folder contains a file with a given name - * - * - parameter fileName: The name of the file to check for - */ - public func containsFile(named fileName: String) -> Bool { - return (try? file(named: fileName)) != nil - } - - /** - * Return whether this folder contains a given file - * - * - parameter file: The file to check for - */ - public func contains(_ file: File) -> Bool { - return containsFile(named: file.name) - } - - /** - * Return a folder with a given name that is contained in this folder - * - * - parameter folderName: The name of the folder to return - * - * - throws: `Folder.PathError.invalid` if the folder couldn't be found - */ - public func subfolder(named folderName: String) throws -> Folder { - return try Folder(path: path + folderName, using: fileManager) - } - - /** - * Return a folder at a given path that is contained in this folder's tree - * - * - parameter folderPath: The subpath of the folder to return. Relative to this folder. - * - * - throws: `Folder.PathError.invalid` if the folder couldn't be found - */ - public func subfolder(atPath folderPath: String) throws -> Folder { - return try Folder(path: path + folderPath, using: fileManager) - } - - /** - * Return whether this folder contains a subfolder with a given name - * - * - parameter folderName: The name of the folder to check for - */ - public func containsSubfolder(named folderName: String) -> Bool { - return (try? subfolder(named: folderName)) != nil - } - - /** - * Return whether this folder contains a given subfolder - * - * - parameter subfolder: The folder to check for - */ - public func contains(_ subfolder: Folder) -> Bool { - return containsSubfolder(named: subfolder.name) - } - - /** - * Create a file in this folder and return it - * - * - parameter fileName: The name of the file to create - * - parameter data: Optionally give any data that the file should contain - * - * - throws: `File.Error.writeFailed` if the file couldn't be created - * - * - returns: The file that was created - */ - @discardableResult public func createFile(named fileName: String, contents data: Data = .init()) throws -> File { - let filePath = path + fileName - - guard fileManager.createFile(atPath: filePath, contents: data, attributes: nil) else { - throw File.Error.writeFailed - } - - return try File(path: filePath, using: fileManager) - } - - /** - * Create a file in this folder and return it - * - * - parameter fileName: The name of the file to create - * - parameter contents: The string content that the file should contain - * - parameter encoding: The encoding that the given string content should be encoded with - * - * - throws: `File.Error.writeFailed` if the file couldn't be created - * - * - returns: The file that was created - */ - @discardableResult public func createFile(named fileName: String, contents: String, encoding: String.Encoding = .utf8) throws -> File { - let file = try createFile(named: fileName) - try file.write(string: contents, encoding: encoding) - return file - } - - /** - * Either return an existing file, or create a new one, for a given name - * - * - parameter fileName: The name of the file to either get or create - * - parameter dataExpression: An expression resulting in any data that a new file should contain. - * Will only be evaluated & used in case a new file is created. - * - * - throws: `File.Error.writeFailed` if the file couldn't be created - */ - @discardableResult public func createFileIfNeeded(withName fileName: String, contents dataExpression: @autoclosure () -> Data = .init()) throws -> File { - if let existingFile = try? file(named: fileName) { - return existingFile + /// Read the contents of the file as a string. + /// - parameter encoding: The encoding to decode the file's data using (default: `UTF8`). + /// - throws: `ReadError` if the file couldn't be read, or if a string couldn't + /// be decoded from the file's contents. + func readAsString(encodedAs encoding: String.Encoding = .utf8) throws -> String { + guard let string = try String(data: read(), encoding: encoding) else { + throw ReadError(path: path, reason: .stringDecodingFailed) } - return try createFile(named: fileName, contents: dataExpression()) - } - - /** - * Create a subfolder of this folder and return it - * - * - parameter folderName: The name of the folder to create - * - * - throws: `Folder.Error.creatingFolderFailed` if the subfolder couldn't be created - * - * - returns: The folder that was created - */ - @discardableResult public func createSubfolder(named folderName: String) throws -> Folder { - let subfolderPath = path + folderName - - do { - try fileManager.createDirectory(atPath: subfolderPath, withIntermediateDirectories: false, attributes: nil) - return try Folder(path: subfolderPath, using: fileManager) - } catch { - throw Error.creatingFolderFailed - } + return string } - /** - * Either return an existing subfolder, or create a new one, for a given name - * - * - parameter folderName: The name of the folder to either get or create - * - * - throws: `Folder.Error.creatingFolderFailed` - */ - @discardableResult public func createSubfolderIfNeeded(withName folderName: String) throws -> Folder { - if let existingFolder = try? subfolder(named: folderName) { - return existingFolder - } + /// Read the contents of the file as an integer. + /// - throws: `ReadError` if the file couldn't be read, or if the file's + /// contents couldn't be converted into an integer. + func readAsInt() throws -> Int { + let string = try readAsString() - return try createSubfolder(named: folderName) - } - - /** - * Create a sequence containing the files that are contained within this folder - * - * - parameter recursive: Whether the files contained in all subfolders of this folder should also be included - * - parameter includeHidden: Whether hidden (dot) files should be included in the sequence (default: false) - * - * If `recursive = true` the folder tree will be traversed depth-first - */ - public func makeFileSequence(recursive: Bool = false, includeHidden: Bool = false) -> FileSystemSequence { - return FileSystemSequence(folder: self, recursive: recursive, includeHidden: includeHidden, using: fileManager) - } - - /** - * Create a sequence containing the folders that are subfolders of this folder - * - * - parameter recursive: Whether the entire folder tree contained under this folder should also be included - * - parameter includeHidden: Whether hidden (dot) files should be included in the sequence (default: false) - * - * If `recursive = true` the folder tree will be traversed depth-first - */ - public func makeSubfolderSequence(recursive: Bool = false, includeHidden: Bool = false) -> FileSystemSequence { - return FileSystemSequence(folder: self, recursive: recursive, includeHidden: includeHidden, using: fileManager) - } - - /** - * Move the contents (both files and subfolders) of this folder to a new parent folder - * - * - parameter newParent: The new parent folder that the contents of this folder should be moved to - * - parameter includeHidden: Whether hidden (dot) files should be moved (default: false) - */ - public func moveContents(to newParent: Folder, includeHidden: Bool = false) throws { - try makeFileSequence(includeHidden: includeHidden).forEach { try $0.move(to: newParent) } - try makeSubfolderSequence(includeHidden: includeHidden).forEach { try $0.move(to: newParent) } - } - - /** - * Empty this folder, removing all of its content - * - * - parameter includeHidden: Whether hidden files (dot) files contained within the folder should also be removed - * - * This will still keep the folder itself on disk. If you wish to delete the folder as well, call `delete()` on it. - */ - public func empty(includeHidden: Bool = false) throws { - try makeFileSequence(includeHidden: includeHidden).forEach { try $0.delete() } - try makeSubfolderSequence(includeHidden: includeHidden).forEach { try $0.delete() } - } - - /** - * Copy this folder to a new folder - * - * - parameter folder: The folder that the folder should be copy to - * - * - throws: `FileSystem.Item.OperationError.copyFailed` if the folder couldn't be copied - */ - @discardableResult public func copy(to folder: Folder) throws -> Folder { - let newPath = folder.path + name - - do { - try fileManager.copyItem(atPath: path, toPath: newPath) - return try Folder(path: newPath) - } catch { - throw OperationError.copyFailed(self) + guard let int = Int(string) else { + throw ReadError(path: path, reason: .notAnInt(string)) } + + return int } } -/// Protocol adopted by file system types that may be iterated over (this protocol is an implementation detail) -public protocol FileSystemIterable { - /// Initialize an instance with a path and a file manager - init(path: String, using fileManager: FileManager) throws +// MARK: - Folders + +/// Type that represents a folder on disk. You can either reference an existing +/// folder by initializing an instance with a `path`, or you can create new +/// subfolders using this type's various `createSubfolder...` APIs. +public struct Folder: Location { + public let storage: Storage + + public init(storage: Storage) { + self.storage = storage + } } -/** - * A sequence used to iterate over file system items - * - * You don't create instances of this class yourself. Instead, you can access various sequences on a `Folder`, for example - * containing its files and subfolders. The sequence is lazily evaluated when you perform operations on it. - */ -public class FileSystemSequence: Sequence, CustomStringConvertible where T: FileSystemIterable { - /// The number of items contained in this sequence. Accessing this causes the sequence to be evaluated. - public var count: Int { - var count = 0 - forEach { _ in count += 1 } - return count - } - - /// An array containing the names of all the items contained in this sequence. Accessing this causes the sequence to be evaluated. - public var names: [String] { - return map { $0.name } +public extension Folder { + /// A sequence of child locations contained within a given folder. + /// You obtain an instance of this type by accessing either `files` + /// or `subfolders` on a `Folder` instance. + struct ChildSequence: Sequence { + fileprivate let folder: Folder + fileprivate let fileManager: FileManager + fileprivate var isRecursive: Bool + fileprivate var includeHidden: Bool + + public func makeIterator() -> ChildIterator { + return ChildIterator( + folder: folder, + fileManager: fileManager, + isRecursive: isRecursive, + includeHidden: includeHidden, + reverseTopLevelTraversal: false + ) + } } - - /// The first item of the sequence. Accessing this causes the sequence to be evaluated until an item is found - public var first: T? { - return makeIterator().next() + + /// The type of iterator used by `ChildSequence`. You don't interact + /// with this type directly. See `ChildSequence` for more information. + struct ChildIterator: IteratorProtocol { + private let folder: Folder + private let fileManager: FileManager + private let isRecursive: Bool + private let includeHidden: Bool + private let reverseTopLevelTraversal: Bool + private lazy var itemNames = loadItemNames() + private var index = 0 + private var nestedIterators = [ChildIterator]() + + fileprivate init(folder: Folder, + fileManager: FileManager, + isRecursive: Bool, + includeHidden: Bool, + reverseTopLevelTraversal: Bool) { + self.folder = folder + self.fileManager = fileManager + self.isRecursive = isRecursive + self.includeHidden = includeHidden + self.reverseTopLevelTraversal = reverseTopLevelTraversal + } + + public mutating func next() -> Child? { + guard index < itemNames.count else { + guard var nested = nestedIterators.first else { + return nil + } + + guard let child = nested.next() else { + nestedIterators.removeFirst() + return next() + } + + nestedIterators[0] = nested + return child + } + + let name = itemNames[index] + index += 1 + + if !includeHidden { + guard !name.hasPrefix(".") else { return next() } + } + + let childPath = folder.path + name.removingPrefix("/") + let childStorage = try? Storage(path: childPath, fileManager: fileManager) + let child = childStorage.map(Child.init) + + if isRecursive { + let childFolder = (child as? Folder) ?? (try? Folder( + storage: Storage(path: childPath, fileManager: fileManager) + )) + + if let childFolder = childFolder { + let nested = ChildIterator( + folder: childFolder, + fileManager: fileManager, + isRecursive: true, + includeHidden: includeHidden, + reverseTopLevelTraversal: false + ) + + nestedIterators.append(nested) + } + } + + return child ?? next() + } + + private mutating func loadItemNames() -> [String] { + let contents = try? fileManager.contentsOfDirectory(atPath: folder.path) + let names = contents?.sorted() ?? [] + return reverseTopLevelTraversal ? names.reversed() : names + } } - - /// The last item of the sequence. Accessing this causes the sequence to be evaluated. - public var last: T? { - var item: T? - forEach { item = $0 } - return item +} + +extension Folder.ChildSequence: CustomStringConvertible { + public var description: String { + return lazy.map({ $0.description }).joined(separator: "\n") } +} - private let folder: Folder - private let isRecursive: Bool - private let includeHidden: Bool - private let fileManager: FileManager +public extension Folder.ChildSequence { + /// Return a new instance of this sequence that'll traverse the folder's + /// contents recursively, in a breadth-first manner. Complexity: `O(1)`. + var recursive: Folder.ChildSequence { + var sequence = self + sequence.isRecursive = true + return sequence + } - fileprivate init(folder: Folder, recursive: Bool, includeHidden: Bool, using fileManager: FileManager) { - self.folder = folder - self.isRecursive = recursive - self.includeHidden = includeHidden - self.fileManager = fileManager + /// Return a new instance of this sequence that'll include all hidden + /// (dot) files when traversing the folder's contents. Complexity: `O(1)`. + var includingHidden: Folder.ChildSequence { + var sequence = self + sequence.includeHidden = true + return sequence } - - /// Create an iterator to use to iterate over the sequence - public func makeIterator() -> FileSystemIterator { - return FileSystemIterator(folder: folder, recursive: isRecursive, includeHidden: includeHidden, using: fileManager) + + /// Count the number of locations contained within this sequence. + /// Complexity: `O(N)`. + func count() -> Int { + return reduce(0) { count, _ in count + 1 } } - - /// Move all the items in this sequence to a new folder. See `FileSystem.Item.move(to:)` for more info. - public func move(to newParent: Folder) throws { - try forEach { try $0.move(to: newParent) } + + /// Gather the names of all of the locations contained within this sequence. + /// Complexity: `O(N)`. + func names() -> [String] { + return map { $0.name } } - // Create a recursive version of this sequence - public var recursive: FileSystemSequence { - return FileSystemSequence( + /// Return the last location contained within this sequence. + /// Complexity: `O(N)`. + func last() -> Child? { + var iterator = Iterator( folder: folder, - recursive: true, + fileManager: fileManager, + isRecursive: isRecursive, includeHidden: includeHidden, - using: fileManager + reverseTopLevelTraversal: !isRecursive ) + + guard isRecursive else { return iterator.next() } + + var child: Child? + + while let nextChild = iterator.next() { + child = nextChild + } + + return child } - public var description: String { - return map { $0.description }.joined(separator: "\n") + /// Return the first location contained within this sequence. + /// Complexity: `O(1)`. + var first: Child? { + var iterator = makeIterator() + return iterator.next() } -} -/// Iterator used to iterate over an instance of `FileSystemSequence` -public class FileSystemIterator: IteratorProtocol where T: FileSystemIterable { - private let folder: Folder - private let recursive: Bool - private let includeHidden: Bool - private let fileManager: FileManager - private lazy var itemNames: [String] = { - self.fileManager.itemNames(inFolderAtPath: self.folder.path) - }() - private lazy var childIteratorQueue = [FileSystemIterator]() - private var currentChildIterator: FileSystemIterator? - - fileprivate init(folder: Folder, recursive: Bool, includeHidden: Bool, using fileManager: FileManager) { - self.folder = folder - self.recursive = recursive - self.includeHidden = includeHidden - self.fileManager = fileManager + /// Move all locations within this sequence to a new parent folder. + /// - parameter folder: The folder to move all locations to. + /// - throws: `LocationError` if the move couldn't be completed. + func move(to folder: Folder) throws { + try forEach { try $0.move(to: folder) } } - - /// Advance the iterator to the next element - public func next() -> T? { - if itemNames.isEmpty { - if let childIterator = currentChildIterator { - if let next = childIterator.next() { - return next - } - } - - guard !childIteratorQueue.isEmpty else { - return nil - } - - currentChildIterator = childIteratorQueue.removeFirst() - return next() - } - - let nextItemName = itemNames.removeFirst() - - guard includeHidden || !nextItemName.hasPrefix(".") else { - return next() - } - - let nextItemPath = folder.path + nextItemName - let nextItem = try? T(path: nextItemPath, using: fileManager) - if recursive, let folder = (nextItem as? Folder) ?? (try? Folder(path: nextItemPath)) { - let child = FileSystemIterator(folder: folder, recursive: true, includeHidden: includeHidden, using: fileManager) - childIteratorQueue.append(child) - } - - return nextItem ?? next() + /// Delete all of the locations within this sequence. All items will + /// be permanently deleted. Use with caution. + /// - throws: `LocationError` if an item couldn't be deleted. Note that + /// all items deleted up to that point won't be recovered. + func delete() throws { + try forEach { try $0.delete() } } } -// MARK: - Private +public extension Folder { + static var kind: LocationKind { + return .folder + } -private extension FileSystem.Item { - enum Kind: CustomStringConvertible { - case file - case folder - - var description: String { - switch self { - case .file: - return "File" - case .folder: - return "Folder" - } - } + /// The folder that the program is currently operating in. + static var current: Folder { + return try! Folder(path: "") } - func loadModificationDate() -> Date { - let attributes = try! fileManager.attributesOfItem(atPath: path) - return attributes[FileAttributeKey.modificationDate] as! Date + /// The root folder of the file system. + static var root: Folder { + return try! Folder(path: "/") } -} -private extension FileManager { - func itemKind(atPath path: String) -> FileSystem.Item.Kind? { - var objCBool: ObjCBool = false - - guard fileExists(atPath: path, isDirectory: &objCBool) else { - return nil - } + /// The current user's Home folder. + static var home: Folder { + return try! Folder(path: "~") + } - if objCBool.boolValue { - return .folder - } - - return .file + /// The system's temporary folder. + static var temporary: Folder { + return try! Folder(path: NSTemporaryDirectory()) } - - func itemNames(inFolderAtPath path: String) -> [String] { - do { - return try contentsOfDirectory(atPath: path).sorted() - } catch { - return [] - } + + /// A sequence containing all of this folder's subfolders. Initially + /// non-recursive, use `recursive` on the returned sequence to change that. + var subfolders: ChildSequence { + return storage.makeChildSequence() } - - func absolutePath(for path: String) throws -> String { - if path.hasPrefix("/") { - return try pathByFillingInParentReferences(for: path) - } - - if path.hasPrefix("~") { - let prefixEndIndex = path.index(after: path.startIndex) - - let path = path.replacingCharacters( - in: path.startIndex.. { + return storage.makeChildSequence() + } - return try pathByFillingInParentReferences(for: path, prependCurrentFolderPath: true) + /// Return a subfolder at a given path within this folder. + /// - parameter path: A relative path within this folder. + /// - throws: `LocationError` if the subfolder couldn't be found. + func subfolder(at path: String) throws -> Folder { + return try storage.subfolder(at: path) } - func parentPath(for path: String) -> String? { - guard path != "/" else { - return nil - } + /// Return a subfolder with a given name. + /// - parameter name: The name of the subfolder to return. + /// - throws: `LocationError` if the subfolder couldn't be found. + func subfolder(named name: String) throws -> Folder { + return try storage.subfolder(at: name) + } - var pathComponents = path.pathComponents + /// Return whether this folder contains a subfolder at a given path. + /// - parameter path: The relative path of the subfolder to look for. + func containsSubfolder(at path: String) -> Bool { + return (try? subfolder(at: path)) != nil + } - if path.hasSuffix("/") { - pathComponents.removeLast(2) - } else { - pathComponents.removeLast() - } + /// Return whether this folder contains a subfolder with a given name. + /// - parameter name: The name of the subfolder to look for. + func containsSubfolder(named name: String) -> Bool { + return (try? subfolder(named: name)) != nil + } - return pathComponents.joined(separator: "/") + /// Create a new subfolder at a given path within this folder. In case + /// the intermediate folders between this folder and the new one don't + /// exist, those will be created as well. This method throws an error + /// if a folder already exists at the given path. + /// - parameter path: The relative path of the subfolder to create. + /// - throws: `WriteError` if the operation couldn't be completed. + @discardableResult + func createSubfolder(at path: String) throws -> Folder { + return try storage.createSubfolder(at: path) } - func pathByFillingInParentReferences(for path: String, prependCurrentFolderPath: Bool = false) throws -> String { - var path = path - var filledIn = false + /// Create a new subfolder with a given name. This method throws an error + /// if a subfolder with the given name already exists. + /// - parameter name: The name of the subfolder to create. + /// - throws: `WriteError` if the operation couldn't be completed. + @discardableResult + func createSubfolder(named name: String) throws -> Folder { + return try storage.createSubfolder(at: name) + } - while let parentReferenceRange = path.range(of: "../") { - let currentFolderPath = String(path[.. Folder { + return try (try? subfolder(at: path)) ?? createSubfolder(at: path) + } - guard let currentFolder = try? Folder(path: currentFolderPath) else { - throw FileSystem.Item.PathError.invalid(path) - } + /// Create a new subfolder with a given name. If a subfolder with the given + /// name already exists, then it will be returned without modification. + /// - parameter name: The name of the subfolder. + /// - throws: `WriteError` if a new folder couldn't be created. + @discardableResult + func createSubfolderIfNeeded(withName name: String) throws -> Folder { + return try (try? subfolder(named: name)) ?? createSubfolder(named: name) + } - guard let parent = currentFolder.parent else { - throw FileSystem.Item.PathError.invalid(path) - } + /// Return a file at a given path within this folder. + /// - parameter path: A relative path within this folder. + /// - throws: `LocationError` if the file couldn't be found. + func file(at path: String) throws -> File { + return try storage.file(at: path) + } - path = path.replacingCharacters(in: path.startIndex.. File { + return try storage.file(at: name) + } + + /// Return whether this folder contains a file at a given path. + /// - parameter path: The relative path of the file to look for. + func containsFile(at path: String) -> Bool { + return (try? file(at: path)) != nil + } + + /// Return whether this folder contains a file with a given name. + /// - parameter name: The name of the file to look for. + func containsFile(named name: String) -> Bool { + return (try? file(named: name)) != nil + } + + /// Create a new file at a given path within this folder. In case + /// the intermediate folders between this folder and the new file don't + /// exist, those will be created as well. This method throws an error + /// if a file already exists at the given path. + /// - parameter path: The relative path of the file to create. + /// - parameter contents: The initial `Data` that the file should contain. + /// - throws: `WriteError` if the operation couldn't be completed. + @discardableResult + func createFile(at path: String, contents: Data? = nil) throws -> File { + return try storage.createFile(at: path, contents: contents) + } + + /// Create a new file with a given name. This method throws an error + /// if a file with the given name already exists. + /// - parameter name: The name of the file to create. + /// - parameter contents: The initial `Data` that the file should contain. + /// - throws: `WriteError` if the operation couldn't be completed. + @discardableResult + func createFile(named fileName: String, contents: Data? = nil) throws -> File { + return try storage.createFile(at: fileName, contents: contents) + } + + /// Create a new file at a given path within this folder. In case + /// the intermediate folders between this folder and the new file don't + /// exist, those will be created as well. If a file already exists at + /// the given path, then it will be returned without modification. + /// - parameter path: The relative path of the file. + /// - parameter contents: The initial `Data` that any newly created file + /// should contain. Will only be evaluated if needed. + /// - throws: `WriteError` if a new file couldn't be created. + @discardableResult + func createFileIfNeeded(at path: String, + contents: @autoclosure () -> Data? = nil) throws -> File { + return try (try? file(at: path)) ?? createFile(at: path) + } + + /// Create a new file with a given name. If a file with the given + /// name already exists, then it will be returned without modification. + /// - parameter name: The name of the file. + /// - parameter contents: The initial `Data` that any newly created file + /// should contain. Will only be evaluated if needed. + /// - throws: `WriteError` if a new file couldn't be created. + @discardableResult + func createFileIfNeeded(withName name: String, + contents: @autoclosure () -> Data? = nil) throws -> File { + return try (try? file(named: name)) ?? createFile(named: name, contents: contents()) + } + + /// Return whether this folder contains a given location as a direct child. + /// - parameter location: The location to find. + func contains(_ location: T) -> Bool { + switch T.kind { + case .file: return containsFile(named: location.name) + case .folder: return containsSubfolder(named: location.name) + } + } + + /// Move the contents of this folder to a new parent + /// - parameter folder: The new parent folder to move this folder's contents to. + /// - parameter includeHidden: Whether hidden files should be included (default: `false`). + /// - throws: `LocationError` if the operation couldn't be completed. + func moveContents(to folder: Folder, includeHidden: Bool = false) throws { + var files = self.files + files.includeHidden = includeHidden + try files.move(to: folder) + + var folders = subfolders + folders.includeHidden = includeHidden + try folders.move(to: folder) + } + + /// Empty this folder, permanently deleting all of its contents. Use with caution. + /// - parameter includeHidden: Whether hidden files should also be deleted (default: `false`). + /// - throws: `LocationError` if the operation couldn't be completed. + func empty(includingHidden includeHidden: Bool = false) throws { + var files = self.files + files.includeHidden = includeHidden + try files.delete() + + var folders = subfolders + folders.includeHidden = includeHidden + try folders.delete() + } +} - if prependCurrentFolderPath { - guard filledIn else { - return currentDirectoryPath + "/" + path - } - } +#if os(macOS) +public extension Folder { + /// The current user's Documents folder + static var documents: Folder? { + let urls = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + guard let url = urls.first else { return nil } + return try? Folder(path: url.relativePath) + } - return path + /// The current user's Library folder + static var library: Folder? { + let urls = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask) + guard let url = urls.first else { return nil } + return try? Folder(path: url.relativePath) } } +#endif -private extension String { - var pathComponents: [String] { - return components(separatedBy: "/") +// MARK: - Errors + +/// Error type thrown by all of Files' throwing APIs. +public struct FilesError: Error { + /// The absolute path that the error occured at. + public var path: String + /// The reason that the error occured. + public var reason: Reason + + /// Initialize an instance with a path and a reason. + /// - parameter path: The absolute path that the error occured at. + /// - parameter reason: The reason that the error occured. + public init(path: String, reason: Reason) { + self.path = path + self.reason = reason } } -private extension ProcessInfo { - var homeFolderPath: String { - return environment["HOME"]! +extension FilesError: CustomStringConvertible { + public var description: String { + return """ + Files encounted an error at '\(path)'. + Reason: \(reason) + """ } } -#if os(Linux) && !(swift(>=4.1)) -private extension ObjCBool { - var boolValue: Bool { return Bool(self) } +/// Enum listing reasons that a location manipulation could fail. +public enum LocationErrorReason { + /// The location couldn't be found. + case missing + /// An empty path was given when refering to a file. + case emptyFilePath + /// The user attempted to rename the file system's root folder. + case cannotRenameRoot + /// A rename operation failed with an underlying system error. + case renameFailed(Error) + /// A move operation failed with an underlying system error. + case moveFailed(Error) + /// A copy operation failed with an underlying system error. + case copyFailed(Error) + /// A delete operation failed with an underlying system error. + case deleteFailed(Error) } -#endif -#if os(macOS) -extension FileSystem { - /// A reference to the document folder used by this file system. - public var documentFolder: Folder? { - guard let url = try? fileManager.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) else { - return nil +/// Enum listing reasons that a write operation could fail. +public enum WriteErrorReason { + /// An empty path was given when writing or creating a location. + case emptyPath + /// A folder couldn't be created because of an underlying system error. + case folderCreationFailed(Error) + /// A file couldn't be created. + case fileCreationFailed + /// A file couldn't be written to because of an underlying system error. + case writeFailed(Error) + /// Failed to encode a string into binary data. + case stringEncodingFailed(String) +} + +/// Enum listing reasons that a read operation could fail. +public enum ReadErrorReason { + /// A file couldn't be read because of an underlying system error. + case readFailed(Error) + /// Failed to decode a given set of data into a string. + case stringDecodingFailed + /// Encountered a string that doesn't contain an integer. + case notAnInt(String) +} + +/// Error thrown by location operations - such as find, move, copy and delete. +public typealias LocationError = FilesError +/// Error thrown by write operations - such as file/folder creation. +public typealias WriteError = FilesError +/// Error thrown by read operations - such as when reading a file's contents. +public typealias ReadError = FilesError + +// MARK: - Private system extensions + +private extension FileManager { + func locationExists(at path: String, kind: LocationKind) -> Bool { + var isFolder: ObjCBool = false + + guard fileExists(atPath: path, isDirectory: &isFolder) else { + return false } - - return try? Folder(path: url.path, using: fileManager) - } - - /// A reference to the library folder used by this file system. - public var libraryFolder: Folder? { - guard let url = try? fileManager.url(for: .libraryDirectory, in: .userDomainMask, appropriateFor: nil, create: false) else { - return nil + + switch kind { + case .file: return !isFolder.boolValue + case .folder: return isFolder.boolValue } - - return try? Folder(path: url.path, using: fileManager) } } -#endif + +private extension String { + func removingPrefix(_ prefix: String) -> String { + guard hasPrefix(prefix) else { return self } + return String(dropFirst(prefix.count)) + } + + func removingSuffix(_ suffix: String) -> String { + guard hasSuffix(suffix) else { return self } + return String(dropLast(suffix.count)) + } + + func appendingSuffixIfNeeded(_ suffix: String) -> String { + guard !hasSuffix(suffix) else { return self } + return appending(suffix) + } +} diff --git a/Tests/FilesTests/FilesTests.swift b/Tests/FilesTests/FilesTests.swift index d31d6be..7076461 100644 --- a/Tests/FilesTests/FilesTests.swift +++ b/Tests/FilesTests/FilesTests.swift @@ -1,7 +1,7 @@ /** * Files * - * Copyright (c) 2017 John Sundell. Licensed under the MIT license, as follows: + * Copyright (c) 2017-2019 John Sundell. Licensed under the MIT license, as follows: * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -64,10 +64,42 @@ class FilesTests: XCTestCase { try file.delete() // Attempting to read the file should now throw an error - try assert(file.read(), throwsError: File.Error.readFailed) + try assert(file.read(), throwsErrorOfType: ReadError.self) // Attempting to create a File instance with the path should now also fail - try assert(File(path: file.path), throwsError: File.PathError.invalid(file.path)) + try assert(File(path: file.path), throwsErrorOfType: LocationError.self) + } + } + + func testCreatingFileAtPath() { + performTest { + let path = "a/b/c.txt" + + XCTAssertFalse(folder.containsFile(at: path)) + try folder.createFile(at: path, contents: Data("Hello".utf8)) + + XCTAssertTrue(folder.containsFile(at: path)) + XCTAssertTrue(folder.containsSubfolder(named: "a")) + XCTAssertTrue(folder.containsSubfolder(at: "a/b")) + + let file = try folder.createFileIfNeeded(at: path) + XCTAssertEqual(try file.readAsString(), "Hello") + } + } + + func testDroppingLeadingSlashWhenCreatingFileAtPath() { + performTest { + let path = "/a/b/c.txt" + + XCTAssertFalse(folder.containsFile(at: path)) + try folder.createFile(at: path, contents: Data("Hello".utf8)) + + XCTAssertTrue(folder.containsFile(at: path)) + XCTAssertTrue(folder.containsSubfolder(named: "a")) + XCTAssertTrue(folder.containsSubfolder(at: "/a/b")) + + let file = try folder.createFileIfNeeded(at: path) + XCTAssertEqual(try file.readAsString(), "Hello") } } @@ -92,10 +124,44 @@ class FilesTests: XCTestCase { try subfolder.delete() // Attempting to create a Folder instance with the path should now fail - try assert(Folder(path: subfolder.path), throwsError: Folder.PathError.invalid(subfolder.path)) + try assert(Folder(path: subfolder.path), throwsErrorOfType: LocationError.self) // The file contained in the folder should now also be deleted - try assert(file.read(), throwsError: File.Error.readFailed) + try assert(file.read(), throwsErrorOfType: ReadError.self) + } + } + + func testCreatingSubfolderAtPath() { + performTest { + let path = "a/b/c" + + XCTAssertFalse(folder.containsSubfolder(at: path)) + try folder.createSubfolder(at: path).createFile(named: "d.txt") + + XCTAssertTrue(folder.containsSubfolder(at: path)) + XCTAssertTrue(folder.containsSubfolder(named: "a")) + XCTAssertTrue(folder.containsSubfolder(at: "a/b")) + XCTAssertTrue(folder.containsFile(at: "a/b/c/d.txt")) + + let subfolder = try folder.createSubfolderIfNeeded(at: path) + XCTAssertEqual(subfolder.files.names(), ["d.txt"]) + } + } + + func testDroppingLeadingSlashWhenCreatingSubfolderAtPath() { + performTest { + let path = "a/b/c" + + XCTAssertFalse(folder.containsSubfolder(at: path)) + try folder.createSubfolder(at: path).createFile(named: "d.txt") + + XCTAssertTrue(folder.containsSubfolder(at: path)) + XCTAssertTrue(folder.containsSubfolder(named: "a")) + XCTAssertTrue(folder.containsSubfolder(at: "/a/b")) + XCTAssertTrue(folder.containsFile(at: "/a/b/c/d.txt")) + + let subfolder = try folder.createSubfolderIfNeeded(at: path) + XCTAssertEqual(subfolder.files.names(), ["d.txt"]) } } @@ -112,7 +178,7 @@ class FilesTests: XCTestCase { try XCTAssertEqual(intFile.readAsInt(), 7) let nonIntFile = try folder.createFile(named: "nonInt", contents: "Not an int".data(using: .utf8)!) - try assert(nonIntFile.readAsInt(), throwsError: File.Error.readFailed) + try assert(nonIntFile.readAsInt(), throwsErrorOfType: ReadError.self) } } @@ -205,7 +271,7 @@ class FilesTests: XCTestCase { let subfolderA = try folder.createSubfolder(named: "A") let subfolderB = try subfolderA.createSubfolder(named: "B") let file = try subfolderB.createFile(named: "C") - try XCTAssertEqual(folder.file(atPath: "A/B/C"), file) + try XCTAssertEqual(folder.file(at: "A/B/C"), file) } } @@ -214,7 +280,7 @@ class FilesTests: XCTestCase { let subfolderA = try folder.createSubfolder(named: "A") let subfolderB = try subfolderA.createSubfolder(named: "B") let subfolderC = try subfolderB.createSubfolder(named: "C") - try XCTAssertEqual(folder.subfolder(atPath: "A/B/C"), subfolderC) + try XCTAssertEqual(folder.subfolder(at: "A/B/C"), subfolderC) } } @@ -222,10 +288,10 @@ class FilesTests: XCTestCase { performTest { try folder.createFile(named: "A") try folder.createFile(named: "B") - XCTAssertEqual(folder.files.count, 2) + XCTAssertEqual(folder.files.count(), 2) try folder.empty() - XCTAssertEqual(folder.files.count, 0) + XCTAssertEqual(folder.files.count(), 0) } } @@ -235,14 +301,14 @@ class FilesTests: XCTestCase { try subfolder.createFile(named: "A") try subfolder.createFile(named: ".B") - XCTAssertEqual(subfolder.makeFileSequence(includeHidden: true).count, 2) + XCTAssertEqual(subfolder.files.includingHidden.count(), 2) // Per default, hidden files should not be deleted try subfolder.empty() - XCTAssertEqual(subfolder.makeFileSequence(includeHidden: true).count, 1) + XCTAssertEqual(subfolder.files.includingHidden.count(), 1) - try subfolder.empty(includeHidden: true) - XCTAssertEqual(folder.files.count, 0) + try subfolder.empty(includingHidden: true) + XCTAssertEqual(folder.files.count(), 0) } } @@ -250,27 +316,27 @@ class FilesTests: XCTestCase { performTest { try folder.createFile(named: "A") try folder.createFile(named: "B") - XCTAssertEqual(folder.files.count, 2) + XCTAssertEqual(folder.files.count(), 2) let subfolder = try folder.createSubfolder(named: "folder") try folder.files.move(to: subfolder) try XCTAssertNotNil(subfolder.file(named: "A")) try XCTAssertNotNil(subfolder.file(named: "B")) - XCTAssertEqual(folder.files.count, 0) + XCTAssertEqual(folder.files.count(), 0) } } func testCopyingFiles() { performTest { let file = try folder.createFile(named: "A") - try file.write(string: "content") + try file.write("content") let subfolder = try folder.createSubfolder(named: "folder") try file.copy(to: subfolder) try XCTAssertNotNil(folder.file(named: "A")) try XCTAssertNotNil(subfolder.file(named: "A")) try XCTAssertEqual(file.read(), subfolder.file(named: "A").read()) - XCTAssertEqual(folder.files.count, 1) + XCTAssertEqual(folder.files.count(), 1) } } @@ -294,8 +360,8 @@ class FilesTests: XCTestCase { try copyingFolder.copy(to: subfolder) XCTAssertTrue(folder.containsSubfolder(named: "A")) XCTAssertTrue(subfolder.containsSubfolder(named: "A")) - XCTAssertEqual(folder.subfolders.count, 2) - XCTAssertEqual(subfolder.subfolders.count, 1) + XCTAssertEqual(folder.subfolders.count(), 2) + XCTAssertEqual(subfolder.subfolders.count(), 1) } } @@ -308,8 +374,8 @@ class FilesTests: XCTestCase { // Hidden files should be excluded by default try folder.createFile(named: ".hidden") - XCTAssertEqual(folder.files.names.sorted(), ["1", "2", "3"]) - XCTAssertEqual(folder.files.count, 3) + XCTAssertEqual(folder.files.names().sorted(), ["1", "2", "3"]) + XCTAssertEqual(folder.files.count(), 3) } } @@ -319,9 +385,9 @@ class FilesTests: XCTestCase { try subfolder.createFile(named: ".hidden") try subfolder.createFile(named: "visible") - let files = subfolder.makeFileSequence(includeHidden: true) - XCTAssertEqual(files.names.sorted(), [".hidden", "visible"]) - XCTAssertEqual(files.count, 2) + let files = subfolder.files.includingHidden + XCTAssertEqual(files.names().sorted(), [".hidden", "visible"]) + XCTAssertEqual(files.count(), 2) } } @@ -344,9 +410,9 @@ class FilesTests: XCTestCase { try subfolder2B.createFile(named: "File2B") let expectedNames = ["File1", "File1A", "File1B", "File2", "File2A", "File2B"] - let sequence = folder.makeFileSequence(recursive: true) - XCTAssertEqual(sequence.names, expectedNames) - XCTAssertEqual(sequence.count, 6) + let sequence = folder.files.recursive + XCTAssertEqual(sequence.names(), expectedNames) + XCTAssertEqual(sequence.count(), 6) } } @@ -356,8 +422,8 @@ class FilesTests: XCTestCase { try folder.createSubfolder(named: "2") try folder.createSubfolder(named: "3") - XCTAssertEqual(folder.subfolders.names.sorted(), ["1", "2", "3"]) - XCTAssertEqual(folder.subfolders.count, 3) + XCTAssertEqual(folder.subfolders.names(), ["1", "2", "3"]) + XCTAssertEqual(folder.subfolders.count(), 3) } } @@ -373,9 +439,9 @@ class FilesTests: XCTestCase { try subfolder2.createSubfolder(named: "2B") let expectedNames = ["1", "1A", "1B", "2", "2A", "2B"] - let sequence = folder.makeSubfolderSequence(recursive: true) - XCTAssertEqual(sequence.names.sorted(), expectedNames) - XCTAssertEqual(sequence.count, 6) + let sequence = folder.subfolders.recursive + XCTAssertEqual(sequence.names().sorted(), expectedNames) + XCTAssertEqual(sequence.count(), 6) } } @@ -390,7 +456,7 @@ class FilesTests: XCTestCase { try subfolder2.createSubfolder(named: "2A") try subfolder2.createSubfolder(named: "2B") - let sequence = folder.makeSubfolderSequence(recursive: true) + let sequence = folder.subfolders.recursive for folder in sequence { try folder.rename(to: "Folder " + folder.name) @@ -398,8 +464,8 @@ class FilesTests: XCTestCase { let expectedNames = ["Folder 1", "Folder 1A", "Folder 1B", "Folder 2", "Folder 2A", "Folder 2B"] - XCTAssertEqual(sequence.names.sorted(), expectedNames) - XCTAssertEqual(sequence.count, 6) + XCTAssertEqual(sequence.names().sorted(), expectedNames) + XCTAssertEqual(sequence.count(), 6) } } @@ -410,7 +476,7 @@ class FilesTests: XCTestCase { try folder.createFile(named: "C") XCTAssertEqual(folder.files.first?.name, "A") - XCTAssertEqual(folder.files.last?.name, "C") + XCTAssertEqual(folder.files.last()?.name, "C") } } @@ -422,7 +488,7 @@ class FilesTests: XCTestCase { let subfolder = try folder.createSubfolder(named: "1") try subfolder.createFile(named: "1A") - let names = folder.files.recursive.names + let names = folder.files.recursive.names() XCTAssertEqual(names, ["A", "B", "1A"]) } } @@ -430,10 +496,10 @@ class FilesTests: XCTestCase { func testModificationDate() { performTest { let subfolder = try folder.createSubfolder(named: "Folder") - XCTAssertTrue(Calendar.current.isDateInToday(subfolder.modificationDate)) + XCTAssertTrue(subfolder.modificationDate.map(Calendar.current.isDateInToday) ?? false) let file = try folder.createFile(named: "File") - XCTAssertTrue(Calendar.current.isDateInToday(file.modificationDate)) + XCTAssertTrue(file.modificationDate.map(Calendar.current.isDateInToday) ?? false) } } @@ -455,7 +521,7 @@ class FilesTests: XCTestCase { func testOpeningFileWithEmptyPathThrows() { performTest { - try assert(File(path: ""), throwsError: File.PathError.empty) + try assert(File(path: ""), throwsErrorOfType: LocationError.self) } } @@ -463,7 +529,7 @@ class FilesTests: XCTestCase { performTest { let file = try folder.createFile(named: "file") try file.delete() - try assert(file.delete(), throwsError: File.OperationError.deleteFailed(file)) + try assert(file.delete(), throwsErrorOfType: LocationError.self) } } @@ -473,7 +539,7 @@ class FilesTests: XCTestCase { try XCTAssertEqual(file.read(), Data()) let data = "New content".data(using: .utf8)! - try file.write(data: data) + try file.write(data) try XCTAssertEqual(file.read(), data) } } @@ -483,7 +549,7 @@ class FilesTests: XCTestCase { let file = try folder.createFile(named: "file") try XCTAssertEqual(file.read(), Data()) - try file.write(string: "New content") + try file.write("New content") try XCTAssertEqual(file.read(), "New content".data(using: .utf8)) } } @@ -492,10 +558,10 @@ class FilesTests: XCTestCase { performTest { let file = try folder.createFile(named: "file") let data = "Old content\n".data(using: .utf8)! - try file.write(data: data) + try file.write(data) let newData = "I'm the appended content 💯\n".data(using: .utf8)! - try file.append(data: newData) + try file.append(newData) try XCTAssertEqual(file.read(), "Old content\nI'm the appended content 💯\n".data(using: .utf8)) } } @@ -503,10 +569,10 @@ class FilesTests: XCTestCase { func testAppendingStringToFile() { performTest { let file = try folder.createFile(named: "file") - try file.write(string: "Old content\n") + try file.write("Old content\n") let newString = "I'm the appended content 💯\n" - try file.append(string: newString) + try file.append(newString) try XCTAssertEqual(file.read(), "Old content\nI'm the appended content 💯\n".data(using: .utf8)) } } @@ -549,16 +615,16 @@ class FilesTests: XCTestCase { try parentFolder.createFile(named: "fileA") try parentFolder.createFile(named: "fileB") - XCTAssertEqual(parentFolder.subfolders.names, ["folderA", "folderB"]) - XCTAssertEqual(parentFolder.files.names, ["fileA", "fileB"]) + XCTAssertEqual(parentFolder.subfolders.names(), ["folderA", "folderB"]) + XCTAssertEqual(parentFolder.files.names(), ["fileA", "fileB"]) let newParentFolder = try folder.createSubfolder(named: "parentB") try parentFolder.moveContents(to: newParentFolder) - XCTAssertEqual(parentFolder.subfolders.names, []) - XCTAssertEqual(parentFolder.files.names, []) - XCTAssertEqual(newParentFolder.subfolders.names, ["folderA", "folderB"]) - XCTAssertEqual(newParentFolder.files.names, ["fileA", "fileB"]) + XCTAssertEqual(parentFolder.subfolders.names(), []) + XCTAssertEqual(parentFolder.files.names(), []) + XCTAssertEqual(newParentFolder.subfolders.names(), ["folderA", "folderB"]) + XCTAssertEqual(newParentFolder.files.names(), ["fileA", "fileB"]) } } @@ -568,21 +634,20 @@ class FilesTests: XCTestCase { try parentFolder.createFile(named: ".hidden") try parentFolder.createSubfolder(named: ".folder") - XCTAssertEqual(parentFolder.makeFileSequence(includeHidden: true).names, [".hidden"]) - XCTAssertEqual(parentFolder.makeSubfolderSequence(includeHidden: true).names, [".folder"]) + XCTAssertEqual(parentFolder.files.includingHidden.names(), [".hidden"]) + XCTAssertEqual(parentFolder.subfolders.includingHidden.names(), [".folder"]) let newParentFolder = try folder.createSubfolder(named: "parentB") try parentFolder.moveContents(to: newParentFolder, includeHidden: true) - XCTAssertEqual(parentFolder.makeFileSequence(includeHidden: true).names, []) - XCTAssertEqual(parentFolder.makeSubfolderSequence(includeHidden: true).names, []) - XCTAssertEqual(newParentFolder.makeFileSequence(includeHidden: true).names, [".hidden"]) - XCTAssertEqual(newParentFolder.makeSubfolderSequence(includeHidden: true).names, [".folder"]) + XCTAssertEqual(parentFolder.files.includingHidden.names(), []) + XCTAssertEqual(parentFolder.subfolders.includingHidden.names(), []) + XCTAssertEqual(newParentFolder.files.includingHidden.names(), [".hidden"]) + XCTAssertEqual(newParentFolder.subfolders.includingHidden.names(), [".folder"]) } } func testAccessingHomeFolder() { - XCTAssertNotNil(FileSystem().homeFolder) XCTAssertNotNil(Folder.home) } @@ -590,7 +655,6 @@ class FilesTests: XCTestCase { performTest { let folder = try Folder(path: "") XCTAssertEqual(FileManager.default.currentDirectoryPath + "/", folder.path) - XCTAssertEqual(FileSystem().currentFolder, folder) XCTAssertEqual(Folder.current, folder) } } @@ -623,52 +687,6 @@ class FilesTests: XCTestCase { } } - func testCreatingFileFromFileSystem() { - performTest { - let fileName = "three" - let filePath = folder.path + "one/two/\(fileName)" - let contents = Data() - let file = try FileSystem().createFile(at: filePath, contents: contents) - - XCTAssertEqual(file.name, fileName) - XCTAssertEqual(file.path, filePath) - - try XCTAssertEqual(File(path: filePath).read(), contents) - } - } - - func testCreateFileFromFileSystemIfNeeded() { - performTest { - let path = folder.path + "one/two/three/file" - let contentA = "Hello".data(using: .utf8)! - let contentB = "World".data(using: .utf8)! - let fileA = try FileSystem().createFileIfNeeded(at: path, contents: contentA) - let fileB = try FileSystem().createFileIfNeeded(at: path, contents: contentB) - - try XCTAssertEqual(fileA.readAsString(), "Hello") - try XCTAssertEqual(fileA.read(), fileB.read()) - } - } - - func testCreatingFolderFromFileSystem() { - performTest { - let folderPath = folder.path + "one/two/three" - try FileSystem().createFolder(at: folderPath) - _ = try Folder(path: folderPath) - } - } - - func testCreatingFolderWithTildePathFromFileSystem() { - performTest { - let fileSystem = FileSystem() - try fileSystem.createFolder(at: "~/.filesTestFolder") - let createdFolder = try fileSystem.homeFolder.subfolder(named: ".filesTestFolder") - - // Cleanup since we're performing a test in the actual home folder - try createdFolder.delete() - } - } - func testCreateFileIfNeeded() { performTest { let fileA = try folder.createFileIfNeeded(withName: "file", contents: "Hello".data(using: .utf8)!) @@ -680,11 +698,11 @@ class FilesTests: XCTestCase { func testCreateFolderIfNeeded() { performTest { - let subfolderA = try FileSystem().createFolderIfNeeded(at: folder.path + "one/two/three") + let subfolderA = try folder.createSubfolderIfNeeded(withName: "Subfolder") try subfolderA.createFile(named: "file") - let subfolderB = try FileSystem().createFolderIfNeeded(at: subfolderA.path) + let subfolderB = try folder.createSubfolderIfNeeded(withName: subfolderA.name) XCTAssertEqual(subfolderA, subfolderB) - XCTAssertEqual(subfolderA.files.count, subfolderB.files.count) + XCTAssertEqual(subfolderA.files.count(), subfolderB.files.count()) XCTAssertEqual(subfolderA.files.first, subfolderB.files.first) } } @@ -695,14 +713,14 @@ class FilesTests: XCTestCase { try subfolderA.createFile(named: "file") let subfolderB = try folder.createSubfolderIfNeeded(withName: "folder") XCTAssertEqual(subfolderA, subfolderB) - XCTAssertEqual(subfolderA.files.count, subfolderB.files.count) + XCTAssertEqual(subfolderA.files.count(), subfolderB.files.count()) XCTAssertEqual(subfolderA.files.first, subfolderB.files.first) } } func testCreatingFileWithString() { performTest { - let file = try folder.createFile(named: "file", contents: "Hello world") + let file = try folder.createFile(named: "file", contents: Data("Hello world".utf8)) XCTAssertEqual(try file.readAsString(), "Hello world") } } @@ -722,14 +740,13 @@ class FilesTests: XCTestCase { performTest { let fileManager = FileManagerMock() - let fileSystem = FileSystem(using: fileManager) - let subfolder = try fileSystem.temporaryFolder.createSubfolder(named: UUID().uuidString) + let subfolder = try folder.managedBy(fileManager).createSubfolder(named: UUID().uuidString) let file = try subfolder.createFile(named: "file") try XCTAssertEqual(file.read(), Data()) // Mock that no files exist, which should call file lookups to fail fileManager.noFilesExist = true - try assert(subfolder.file(named: "file"), throwsError: File.PathError.invalid(file.path)) + try assert(subfolder.file(named: "file"), throwsErrorOfType: LocationError.self) } } @@ -754,6 +771,28 @@ class FilesTests: XCTestCase { XCTAssertTrue(folder.contains(subfolderB)) } } + + func testErrorDescriptions() { + let missingError = FilesError( + path: "/some/path", + reason: LocationErrorReason.missing + ) + + XCTAssertEqual(missingError.description, """ + Files encounted an error at '/some/path'. + Reason: missing + """) + + let encodingError = FilesError( + path: "/some/path", + reason: WriteErrorReason.stringEncodingFailed("Hello") + ) + + XCTAssertEqual(encodingError.description, """ + Files encounted an error at '/some/path'. + Reason: stringEncodingFailed(\"Hello\") + """) + } // MARK: - Utilities @@ -766,14 +805,13 @@ class FilesTests: XCTestCase { } } - private func assert(_ expression: @autoclosure () throws -> T, throwsError expectedError: E) where E: Equatable { + private func assert(_ expression: @autoclosure () throws -> T, + throwsErrorOfType expectedError: E.Type) { do { _ = try expression() XCTFail("Expected error to be thrown") - } catch let error as E { - XCTAssertEqual(error, expectedError) } catch { - XCTFail("Unexpected error type: \(type(of: error))") + XCTAssertTrue(error is E) } } @@ -781,7 +819,11 @@ class FilesTests: XCTestCase { static var allTests = [ ("testCreatingAndDeletingFile", testCreatingAndDeletingFile), + ("testCreatingFileAtPath", testCreatingFileAtPath), + ("testDroppingLeadingSlashWhenCreatingFileAtPath", testDroppingLeadingSlashWhenCreatingFileAtPath), ("testCreatingAndDeletingFolder", testCreatingAndDeletingFolder), + ("testCreatingSubfolderAtPath", testCreatingSubfolderAtPath), + ("testDroppingLeadingSlashWhenCreatingSubfolderAtPath", testDroppingLeadingSlashWhenCreatingSubfolderAtPath), ("testReadingFileAsString", testReadingFileAsString), ("testReadingFileAsInt", testReadingFileAsInt), ("testRenamingFile", testRenamingFile), @@ -826,28 +868,25 @@ class FilesTests: XCTestCase { ("testNameExcludingExtensionWithLongFileName", testNameExcludingExtensionWithLongFileName), ("testRelativePaths", testRelativePaths), ("testRelativePathIsAbsolutePathForNonParent", testRelativePathIsAbsolutePathForNonParent), - ("testCreatingFileFromFileSystem", testCreatingFileFromFileSystem), - ("testCreateFileFromFileSystemIfNeeded", testCreateFileFromFileSystemIfNeeded), - ("testCreatingFolderFromFileSystem", testCreatingFolderFromFileSystem), - ("testCreatingFolderWithTildePathFromFileSystem", testCreatingFolderWithTildePathFromFileSystem), ("testCreateFileIfNeeded", testCreateFileIfNeeded), ("testCreateFolderIfNeeded", testCreateFolderIfNeeded), ("testCreateSubfolderIfNeeded", testCreateSubfolderIfNeeded), ("testCreatingFileWithString", testCreatingFileWithString), ("testUsingCustomFileManager", testUsingCustomFileManager), ("testFolderContainsFile", testFolderContainsFile), - ("testFolderContainsSubfolder", testFolderContainsSubfolder) + ("testFolderContainsSubfolder", testFolderContainsSubfolder), + ("testErrorDescriptions", testErrorDescriptions) ] } #if os(macOS) extension FilesTests { func testAccessingDocumentFolder() { - XCTAssertNotNil(FileSystem().documentFolder, "Document folder should be available.") + XCTAssertNotNil(Folder.documents, "Document folder should be available.") } func testAccessingLibraryFolder() { - XCTAssertNotNil(FileSystem().libraryFolder, "Library folder should be available.") + XCTAssertNotNil(Folder.library, "Library folder should be available.") } } #endif