Flesh out Dir a lot more

This commit is contained in:
Sami Samhuri 2025-08-17 14:17:50 -07:00
parent c74842ef76
commit f85da3e7b5
No known key found for this signature in database
5 changed files with 695 additions and 72 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
build
xcuserdata

View file

@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
7B1B71E12E52784D008EDC0E /* Glob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1B71E02E52784D008EDC0E /* Glob.swift */; };
7B5064B92BD9F236009CEFF9 /* FileOtter.docc in Sources */ = {isa = PBXBuildFile; fileRef = 7B5064B82BD9F236009CEFF9 /* FileOtter.docc */; };
7B5064BF2BD9F236009CEFF9 /* FileOtter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7B5064B42BD9F236009CEFF9 /* FileOtter.framework */; };
7B5064C42BD9F236009CEFF9 /* FileOtterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5064C32BD9F236009CEFF9 /* FileOtterTests.swift */; };
@ -27,6 +28,7 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
7B1B71E02E52784D008EDC0E /* Glob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Glob.swift; sourceTree = "<group>"; };
7B5064B42BD9F236009CEFF9 /* FileOtter.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = FileOtter.framework; sourceTree = BUILT_PRODUCTS_DIR; };
7B5064B72BD9F236009CEFF9 /* FileOtter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FileOtter.h; sourceTree = "<group>"; };
7B5064B82BD9F236009CEFF9 /* FileOtter.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = FileOtter.docc; sourceTree = "<group>"; };
@ -82,6 +84,7 @@
7B5064B82BD9F236009CEFF9 /* FileOtter.docc */,
7B5064D02BD9F322009CEFF9 /* File.swift */,
7B5064D22BD9F339009CEFF9 /* Dir.swift */,
7B1B71E02E52784D008EDC0E /* Glob.swift */,
);
path = FileOtter;
sourceTree = "<group>";
@ -205,6 +208,7 @@
buildActionMask = 2147483647;
files = (
7B5064B92BD9F236009CEFF9 /* FileOtter.docc in Sources */,
7B1B71E12E52784D008EDC0E /* Glob.swift in Sources */,
7B5064D12BD9F322009CEFF9 /* File.swift in Sources */,
7B5064D32BD9F339009CEFF9 /* Dir.swift in Sources */,
);

View file

@ -7,7 +7,7 @@
import Foundation
public struct Dir: Equatable, Hashable, RandomAccessCollection {
public struct Dir: Equatable, Hashable, RandomAccessCollection, CustomStringConvertible, CustomDebugStringConvertible {
public let startIndex: Int
public let endIndex: Int
@ -15,7 +15,7 @@ public struct Dir: Equatable, Hashable, RandomAccessCollection {
public let url: URL
public init(url: URL) throws {
self.init(url: url, children: try Dir.children(url))
try self.init(url: url, children: Dir.children(url))
}
private let children: [URL]
@ -26,96 +26,124 @@ public struct Dir: Equatable, Hashable, RandomAccessCollection {
startIndex = children.startIndex
endIndex = children.endIndex
}
public var description: String {
url.path
}
public var debugDescription: String {
"<Dir:\(url.path)>"
}
}
// MARK: - Well-known Directories
extension Dir {
public static var caches: URL {
public extension Dir {
static var caches: URL {
URL.cachesDirectory
}
public static var current: URL {
URL.currentDirectory()
}
public static var documents: URL {
static var documents: URL {
URL.documentsDirectory
}
public static var home: URL {
static var home: URL {
URL.homeDirectory
}
public static var library: URL {
static var library: URL {
URL.libraryDirectory
}
public static var pwd: URL {
.currentDirectory()
static var pwd: URL {
URL.currentDirectory()
}
public static var getwd: URL {
.currentDirectory()
static var tmp: URL {
URL.temporaryDirectory
}
}
// MARK: - Mutations
extension Dir {
@discardableResult
public static func chdir(_ url: URL) -> Bool {
FileManager.default.changeCurrentDirectoryPath(url.path)
public extension Dir {
static func chdir(_ url: URL) throws {
guard FileManager.default.changeCurrentDirectoryPath(url.path) else {
throw CocoaError(.fileNoSuchFile, userInfo: [NSFilePathErrorKey: url.path])
}
}
@discardableResult
public static func chdir<T>(_ url: URL, block: (URL) -> T) -> T {
static func chdir<T>(_ url: URL, block: (URL) throws -> T) rethrows -> T {
let previousDir = pwd
FileManager.default.changeCurrentDirectoryPath(url.path)
defer {
FileManager.default.changeCurrentDirectoryPath(previousDir.path)
}
return block(url)
return try block(url)
}
static func unlink(_ url: URL) throws {
try FileManager.default.removeItem(at: url)
}
static func rmdir(_ url: URL) throws {
try unlink(url)
}
static func mkdir(_ url: URL, permissions: Int = 0o755) throws {
let attributes: [FileAttributeKey: Any] = [
.posixPermissions: permissions,
]
try FileManager.default.createDirectory(
at: url,
withIntermediateDirectories: false,
attributes: attributes
)
}
@discardableResult
public static func unlink(_ url: URL) -> Bool {
do {
try FileManager.default.removeItem(at: url)
return true
} catch {
return false
static func mktmpdir(prefix: String = "d", suffix: String = "") throws -> URL {
let tmpBase = URL.temporaryDirectory
let dirName = suffix.isEmpty ? "\(prefix)-\(UUID().uuidString)" : "\(prefix)-\(UUID().uuidString)-\(suffix)"
let tmpDir = tmpBase.appendingPathComponent(dirName)
try FileManager.default.createDirectory(
at: tmpDir,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
return tmpDir
}
@discardableResult
static func mktmpdir<T>(
prefix: String = "d",
suffix: String = "",
_ block: (URL) throws -> T
) throws -> T {
let tmpDir = try mktmpdir(prefix: prefix, suffix: suffix)
defer {
try? FileManager.default.removeItem(at: tmpDir)
}
}
@discardableResult
public static func rmdir(_ url: URL) -> Bool {
unlink(url)
}
@discardableResult
public static func delete(_ url: URL) -> Bool {
unlink(url)
return try block(tmpDir)
}
}
// MARK: - Reading Contents
extension Dir {
public static func children(_ url: URL) throws -> [URL] {
public extension Dir {
static func children(_ url: URL) throws -> [URL] {
try FileManager.default
.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
}
public static func entries(_ url: URL) throws -> [String] {
#warning("TODO: implement this ... maybe, it's dumb")
return []
}
public static func exists(_ url: URL) throws -> Bool {
static func exists(_ url: URL) throws -> Bool {
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
return exists && isDirectory.boolValue
}
public static func isEmpty(_ url: URL) throws -> Bool {
static func isEmpty(_ url: URL) throws -> Bool {
try FileManager.default
.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
.isEmpty
@ -123,28 +151,35 @@ extension Dir {
}
// MARK: - Globbing
extension Dir {
public static func glob(base: URL? = nil, _ patterns: String...) -> [URL] {
public extension Dir {
static func glob(base: URL? = nil, _ patterns: String...) -> [URL] {
_glob(base: base, patterns: patterns)
}
public static subscript(base: URL? = nil, _ patterns: String...) -> [URL] {
static subscript(base: URL?, _ patterns: String...) -> [URL] {
_glob(base: base, patterns: patterns)
}
static subscript(_ patterns: String...) -> [URL] {
_glob(base: nil, patterns: Array(patterns))
}
private static func _glob(base: URL?, patterns: [String]) -> [URL] {
#warning("TODO: implement me")
return []
patterns.flatMap { pattern in
globstar(pattern, base: base)
}.map { URL(fileURLWithPath: $0) }
}
}
// MARK: - RandomAccessCollection
extension Dir {
public func makeIterator() -> any IteratorProtocol<URL> {
public extension Dir {
func makeIterator() -> any IteratorProtocol<URL> {
children.makeIterator()
}
public subscript(position: Int) -> URL {
subscript(position: Int) -> URL {
children[position]
}
}

121
FileOtter/Glob.swift Normal file
View file

@ -0,0 +1,121 @@
//
// Glob.swift
// FileOtter
//
// Created by Sami Samhuri on 2025-08-17.
//
import Foundation
#if os(Linux)
import Glibc
#else
import Darwin
#endif
/// Expand a glob pattern supporting ** (recursive), *, ?, and [].
/// Examples:
/// "src/**/*.swift"
/// "/var/log/**/app*.log"
func globstar(_ pattern: String, base: URL? = nil) -> [URL] {
// Normalize and split into path components
let comps = pattern.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
var results: [String] = []
var seenDirs = Set<String>() // canonical paths to avoid cycles if symlinks appear
// Cache frequently used objects
let fm = FileManager.default
let globMetaChars = CharacterSet(charactersIn: "*?[")
func isDir(_ path: String) -> Bool {
var isDirectory: ObjCBool = false
if fm.fileExists(atPath: path, isDirectory: &isDirectory) {
return isDirectory.boolValue
}
return false
}
func listDir(_ path: String) -> [String] {
(try? fm.contentsOfDirectory(atPath: path)) ?? []
}
// fnmatch against a single path segment (no '/')
@inline(__always)
func matchSegment(_ name: String, pat: String) -> Bool {
name.withCString { nPtr in
pat.withCString { pPtr in
// FNM_PERIOD -> leading '.' must be matched explicitly (shell-like)
// FNM_NOESCAPE -> backslashes are treated literally
fnmatch(pPtr, nPtr, FNM_PERIOD | FNM_NOESCAPE) == 0
}
}
}
func real(_ path: String) -> String {
URL(fileURLWithPath: path).standardizedFileURL.path
}
func walk(_ base: String, _ idx: Int) {
if idx == comps.count {
// Only return existing paths
if FileManager.default.fileExists(atPath: base) {
results.append(real(base))
}
return
}
let part = comps[idx]
if part == "**" {
// Option 1: ** matches zero segments
walk(base, idx + 1)
// Option 2: ** matches one or more directory segments
// Recurse into subdirs breadth-first
let dirPath = base.isEmpty ? "/" : base
let key = real(dirPath)
if seenDirs.contains(key) { return }
seenDirs.insert(key)
if isDir(dirPath) {
let dirPathNS = dirPath as NSString // Cache the NSString conversion
for entry in listDir(dirPath) {
let child = dirPathNS.appendingPathComponent(entry)
if isDir(child) {
// Keep idx the same to allow ** to consume multiple levels
walk(child, idx)
}
}
}
return
}
// Non-** component. If it has no glob metachar, fast-path.
let hasMeta = part.rangeOfCharacter(from: globMetaChars) != nil
if !hasMeta {
let next = (base as NSString).appendingPathComponent(part)
walk(next, idx + 1)
return
}
// Segment glob (*, ?, []) matches names in this directory level only
let dirPath = base.isEmpty ? "/" : base
if !isDir(dirPath) { return }
let dirPathNS = dirPath as NSString // Cache the NSString conversion
for entry in listDir(dirPath) {
if matchSegment(entry, pat: part) {
let next = dirPathNS.appendingPathComponent(entry)
walk(next, idx + 1)
}
}
}
// Kick off
let isAbs = pattern.hasPrefix("/")
let cwd = (base ?? URL.currentDirectory()).path
walk(isAbs ? "/" : cwd, 0)
// De-dup and sort for stability
return Array(Set(results)).sorted()
}

View file

@ -5,32 +5,493 @@
// Created by Sami Samhuri on 2024-04-24.
//
import XCTest
@testable import FileOtter
import XCTest
class FileOtterTests: XCTestCase {
final class DirTests: XCTestCase {
var tempDir: URL!
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
tempDir = URL.temporaryDirectory
.appendingPathComponent("FileOtterTests-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
// Any test you write for XCTest can be annotated as throws and async.
// Mark your test throws to produce an unexpected failure when your test encounters an uncaught error.
// Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards.
}
func testPerformanceExample() throws {
// This is an example of a performance test case.
self.measure {
// Put the code you want to measure the time of here.
if FileManager.default.fileExists(atPath: tempDir.path) {
try FileManager.default.removeItem(at: tempDir)
}
}
// MARK: - Well-known Directories Tests
func testCachesDirectory() {
let caches = Dir.caches
XCTAssertTrue(FileManager.default.fileExists(atPath: caches.path))
XCTAssertTrue(caches.path.contains("Caches"))
}
func testPwd() {
let pwd = Dir.pwd
XCTAssertEqual(pwd, URL.currentDirectory())
XCTAssertTrue(FileManager.default.fileExists(atPath: pwd.path))
}
func testDocumentsDirectory() {
let documents = Dir.documents
XCTAssertTrue(FileManager.default.fileExists(atPath: documents.path))
XCTAssertTrue(documents.path.contains("Documents"))
}
func testHomeDirectory() {
let home = Dir.home
XCTAssertTrue(FileManager.default.fileExists(atPath: home.path))
XCTAssertEqual(home.path, URL.homeDirectory.path)
}
func testLibraryDirectory() {
let library = Dir.library
XCTAssertTrue(FileManager.default.fileExists(atPath: library.path))
XCTAssertTrue(library.path.contains("Library"))
}
// MARK: - chdir Tests
func testChdirChangesDirectory() throws {
let originalDir = Dir.pwd
XCTAssertNoThrow(try Dir.chdir(tempDir))
XCTAssertEqual(Dir.pwd.resolvingSymlinksInPath().path, tempDir.resolvingSymlinksInPath().path)
try Dir.chdir(originalDir)
}
func testChdirWithBlock() {
let originalDir = Dir.pwd
let testFile = tempDir.appendingPathComponent("test.txt")
let result = Dir.chdir(tempDir) { url in
XCTAssertEqual(Dir.pwd.resolvingSymlinksInPath().path, tempDir.resolvingSymlinksInPath().path)
XCTAssertEqual(url.resolvingSymlinksInPath().path, tempDir.resolvingSymlinksInPath().path)
try? "Testing chdir block".write(to: testFile, atomically: true, encoding: .utf8)
return "success"
}
XCTAssertEqual(result, "success")
XCTAssertEqual(Dir.pwd.resolvingSymlinksInPath().path, originalDir.resolvingSymlinksInPath().path)
XCTAssertTrue(FileManager.default.fileExists(atPath: testFile.path))
}
func testChdirWithBlockRestoresDirectoryOnError() {
let originalDir = Dir.pwd
do {
try Dir.chdir(tempDir) { url in
// Verify we're in the temp directory
let currentPath = Dir.pwd.resolvingSymlinksInPath().path
let expectedPath = tempDir.resolvingSymlinksInPath().path
XCTAssertEqual(currentPath, expectedPath)
// Also verify the passed URL matches
XCTAssertEqual(url.resolvingSymlinksInPath().path, expectedPath)
throw NSError(domain: "TestError", code: 1)
}
XCTFail("Should have thrown an error")
} catch {
// Verify we're back in the original directory
XCTAssertEqual(Dir.pwd.resolvingSymlinksInPath().path, originalDir.resolvingSymlinksInPath().path)
}
}
// MARK: - unlink/rmdir Tests
func testUnlinkRemovesDirectory() throws {
let dirToRemove = tempDir.appendingPathComponent("dir-to-remove")
try FileManager.default.createDirectory(at: dirToRemove, withIntermediateDirectories: true)
XCTAssertTrue(FileManager.default.fileExists(atPath: dirToRemove.path))
XCTAssertNoThrow(try Dir.unlink(dirToRemove))
XCTAssertFalse(FileManager.default.fileExists(atPath: dirToRemove.path))
}
func testRmdirRemovesDirectory() throws {
let dirToRemove = tempDir.appendingPathComponent("dir-to-rmdir")
try FileManager.default.createDirectory(at: dirToRemove, withIntermediateDirectories: true)
XCTAssertTrue(FileManager.default.fileExists(atPath: dirToRemove.path))
XCTAssertNoThrow(try Dir.rmdir(dirToRemove))
XCTAssertFalse(FileManager.default.fileExists(atPath: dirToRemove.path))
}
func testUnlinkThrowsForNonExistentDirectory() {
let nonExistent = tempDir.appendingPathComponent("does-not-exist")
XCTAssertThrowsError(try Dir.unlink(nonExistent))
}
// MARK: - Reading Contents Tests
func testChildren() throws {
let file1 = tempDir.appendingPathComponent("file1.txt")
let file2 = tempDir.appendingPathComponent("file2.txt")
let subdir = tempDir.appendingPathComponent("subdir")
try "Content 1".write(to: file1, atomically: true, encoding: .utf8)
try "Content 2".write(to: file2, atomically: true, encoding: .utf8)
try FileManager.default.createDirectory(at: subdir, withIntermediateDirectories: true)
let children = try Dir.children(tempDir)
XCTAssertEqual(children.count, 3)
let childNames = children.map { $0.lastPathComponent }.sorted()
XCTAssertEqual(childNames, ["file1.txt", "file2.txt", "subdir"])
}
func testExists() throws {
XCTAssertTrue(try Dir.exists(tempDir))
XCTAssertTrue(try Dir.exists(Dir.home))
let nonExistent = tempDir.appendingPathComponent("not-a-directory")
XCTAssertFalse(try Dir.exists(nonExistent))
let fileNotDir = tempDir.appendingPathComponent("file.txt")
try "I'm a file".write(to: fileNotDir, atomically: true, encoding: .utf8)
XCTAssertFalse(try Dir.exists(fileNotDir))
}
func testIsEmpty() throws {
XCTAssertTrue(try Dir.isEmpty(tempDir))
let file = tempDir.appendingPathComponent("file.txt")
try "Content".write(to: file, atomically: true, encoding: .utf8)
XCTAssertFalse(try Dir.isEmpty(tempDir))
}
// MARK: - Dir Struct and Collection Tests
func testDirInitialization() throws {
let file1 = tempDir.appendingPathComponent("a.txt")
let file2 = tempDir.appendingPathComponent("b.txt")
try "A".write(to: file1, atomically: true, encoding: .utf8)
try "B".write(to: file2, atomically: true, encoding: .utf8)
let dir = try Dir(url: tempDir)
XCTAssertEqual(dir.url, tempDir)
XCTAssertEqual(dir.count, 2)
}
func testDirSubscript() throws {
let file1 = tempDir.appendingPathComponent("1.txt")
let file2 = tempDir.appendingPathComponent("2.txt")
let file3 = tempDir.appendingPathComponent("3.txt")
try "One".write(to: file1, atomically: true, encoding: .utf8)
try "Two".write(to: file2, atomically: true, encoding: .utf8)
try "Three".write(to: file3, atomically: true, encoding: .utf8)
let dir = try Dir(url: tempDir)
XCTAssertEqual(dir.count, 3)
let firstItem = dir[0]
XCTAssertTrue(["1.txt", "2.txt", "3.txt"].contains(firstItem.lastPathComponent))
}
func testDirIteration() throws {
let files = ["rock.mp3", "jazz.mp3", "punk.mp3"]
for filename in files {
let file = tempDir.appendingPathComponent(filename)
try filename.write(to: file, atomically: true, encoding: .utf8)
}
let dir = try Dir(url: tempDir)
var foundFiles: [String] = []
for url in dir {
foundFiles.append(url.lastPathComponent)
}
XCTAssertEqual(foundFiles.sorted(), files.sorted())
}
func testDirEquality() throws {
let dir1 = try Dir(url: tempDir)
let dir2 = try Dir(url: tempDir)
XCTAssertEqual(dir1, dir2)
let otherDir = URL.temporaryDirectory
.appendingPathComponent("other-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: otherDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: otherDir) }
let dir3 = try Dir(url: otherDir)
XCTAssertNotEqual(dir1, dir3)
}
// MARK: - Glob Tests
func testGlobBasicWildcard() throws {
// Create test files
try "content1".write(to: tempDir.appendingPathComponent("file1.txt"), atomically: true, encoding: .utf8)
try "content2".write(to: tempDir.appendingPathComponent("file2.txt"), atomically: true, encoding: .utf8)
try "content3".write(to: tempDir.appendingPathComponent("file3.md"), atomically: true, encoding: .utf8)
let results = Dir.glob(base: tempDir, "*.txt")
XCTAssertEqual(results.count, 2)
let filenames = results.map { $0.lastPathComponent }.sorted()
XCTAssertEqual(filenames, ["file1.txt", "file2.txt"])
}
func testGlobSubscript() throws {
// Create test files
try "swift1".write(to: tempDir.appendingPathComponent("app.swift"), atomically: true, encoding: .utf8)
try "swift2".write(to: tempDir.appendingPathComponent("lib.swift"), atomically: true, encoding: .utf8)
try "objc".write(to: tempDir.appendingPathComponent("bridge.m"), atomically: true, encoding: .utf8)
let swiftFiles = Dir[tempDir, "*.swift"]
XCTAssertEqual(swiftFiles.count, 2)
let allFiles = Dir[tempDir, "*.*"]
XCTAssertEqual(allFiles.count, 3)
}
func testGlobQuestionMark() throws {
// Create test files with pattern
try "a".write(to: tempDir.appendingPathComponent("a1.txt"), atomically: true, encoding: .utf8)
try "b".write(to: tempDir.appendingPathComponent("b2.txt"), atomically: true, encoding: .utf8)
try "c".write(to: tempDir.appendingPathComponent("abc.txt"), atomically: true, encoding: .utf8)
let results = Dir.glob(base: tempDir, "??.txt")
XCTAssertEqual(results.count, 2)
let filenames = results.map { $0.lastPathComponent }.sorted()
XCTAssertEqual(filenames, ["a1.txt", "b2.txt"])
}
func testGlobCharacterClass() throws {
// Create test files
try "1".write(to: tempDir.appendingPathComponent("test1.txt"), atomically: true, encoding: .utf8)
try "2".write(to: tempDir.appendingPathComponent("test2.txt"), atomically: true, encoding: .utf8)
try "a".write(to: tempDir.appendingPathComponent("testa.txt"), atomically: true, encoding: .utf8)
let numberFiles = Dir.glob(base: tempDir, "test[0-9].txt")
XCTAssertEqual(numberFiles.count, 2)
let letterFiles = Dir.glob(base: tempDir, "test[a-z].txt")
XCTAssertEqual(letterFiles.count, 1)
XCTAssertEqual(letterFiles.first?.lastPathComponent, "testa.txt")
}
func testGlobRecursive() throws {
// Create nested directory structure
let subdir1 = tempDir.appendingPathComponent("src")
let subdir2 = subdir1.appendingPathComponent("lib")
try FileManager.default.createDirectory(at: subdir2, withIntermediateDirectories: true)
try "root".write(to: tempDir.appendingPathComponent("root.swift"), atomically: true, encoding: .utf8)
try "src".write(to: subdir1.appendingPathComponent("main.swift"), atomically: true, encoding: .utf8)
try "lib".write(to: subdir2.appendingPathComponent("utils.swift"), atomically: true, encoding: .utf8)
try "lib2".write(to: subdir2.appendingPathComponent("helpers.swift"), atomically: true, encoding: .utf8)
// Test ** for recursive matching
let allSwiftFiles = Dir.glob(base: tempDir, "**/*.swift")
XCTAssertEqual(allSwiftFiles.count, 4)
// Test ** matching zero segments
let srcSwiftFiles = Dir.glob(base: tempDir, "src/**/*.swift")
XCTAssertEqual(srcSwiftFiles.count, 3) // main.swift, utils.swift, helpers.swift
// Test specific path with **
let libSwiftFiles = Dir.glob(base: tempDir, "**/lib/*.swift")
XCTAssertEqual(libSwiftFiles.count, 2) // utils.swift, helpers.swift
}
func testGlobNoMatches() {
let results = Dir.glob(base: tempDir, "*.nonexistent")
XCTAssertTrue(results.isEmpty)
let subscriptResults = Dir[tempDir, "no-such-file.*"]
XCTAssertTrue(subscriptResults.isEmpty)
}
func testGlobHiddenFiles() throws {
// Create hidden file
try "hidden".write(to: tempDir.appendingPathComponent(".hidden.txt"), atomically: true, encoding: .utf8)
try "visible".write(to: tempDir.appendingPathComponent("visible.txt"), atomically: true, encoding: .utf8)
// By default, * should not match hidden files (FNM_PERIOD flag)
let starResults = Dir.glob(base: tempDir, "*.txt")
XCTAssertEqual(starResults.count, 1)
XCTAssertEqual(starResults.first?.lastPathComponent, "visible.txt")
// Explicitly matching hidden files
let hiddenResults = Dir.glob(base: tempDir, ".*.txt")
XCTAssertEqual(hiddenResults.count, 1)
XCTAssertEqual(hiddenResults.first?.lastPathComponent, ".hidden.txt")
}
// MARK: - Debug Description Tests
func testDebugDescription() throws {
let dir = try Dir(url: tempDir)
let debugDescription = String(reflecting: dir)
XCTAssertEqual(debugDescription, "<Dir:\(tempDir.path)>")
let homeDir = try Dir(url: Dir.home)
let homeDebugDescription = String(reflecting: homeDir)
XCTAssertEqual(homeDebugDescription, "<Dir:\(Dir.home.path)>")
}
func testDescription() throws {
let dir = try Dir(url: tempDir)
let description = String(describing: dir)
XCTAssertEqual(description, tempDir.path)
let homeDir = try Dir(url: Dir.home)
let homeDescription = String(describing: homeDir)
XCTAssertEqual(homeDescription, Dir.home.path)
}
// MARK: - tmpdir Tests
func testTmp() {
let tmp = Dir.tmp
XCTAssertTrue(FileManager.default.fileExists(atPath: tmp.path))
var isDirectory: ObjCBool = false
FileManager.default.fileExists(atPath: tmp.path, isDirectory: &isDirectory)
XCTAssertTrue(isDirectory.boolValue)
XCTAssertEqual(tmp, URL.temporaryDirectory)
}
// MARK: - mkdir Tests
func testMkdir() throws {
let newDir = tempDir.appendingPathComponent("test-mkdir")
XCTAssertFalse(FileManager.default.fileExists(atPath: newDir.path))
try Dir.mkdir(newDir)
XCTAssertTrue(FileManager.default.fileExists(atPath: newDir.path))
var isDirectory: ObjCBool = false
FileManager.default.fileExists(atPath: newDir.path, isDirectory: &isDirectory)
XCTAssertTrue(isDirectory.boolValue)
}
func testMkdirWithPermissions() throws {
let newDir = tempDir.appendingPathComponent("test-mkdir-perms")
try Dir.mkdir(newDir, permissions: 0o700)
let attributes = try FileManager.default.attributesOfItem(atPath: newDir.path)
let permissions = attributes[.posixPermissions] as? Int
XCTAssertNotNil(permissions)
#if os(macOS) || os(Linux)
XCTAssertEqual(permissions! & 0o777, 0o700)
#endif
}
func testMkdirFailsIfDirectoryExists() throws {
let existingDir = tempDir.appendingPathComponent("existing")
try Dir.mkdir(existingDir)
XCTAssertThrowsError(try Dir.mkdir(existingDir)) { error in
XCTAssertTrue(error is CocoaError)
}
}
// MARK: - mktmpdir Tests
func testMktmpdir() throws {
let tmpDir = try Dir.mktmpdir()
XCTAssertTrue(FileManager.default.fileExists(atPath: tmpDir.path))
XCTAssertTrue(tmpDir.path.contains("d-"))
var isDirectory: ObjCBool = false
FileManager.default.fileExists(atPath: tmpDir.path, isDirectory: &isDirectory)
XCTAssertTrue(isDirectory.boolValue)
let attributes = try FileManager.default.attributesOfItem(atPath: tmpDir.path)
let permissions = attributes[.posixPermissions] as? Int
XCTAssertNotNil(permissions)
#if os(macOS) || os(Linux)
XCTAssertEqual(permissions! & 0o777, 0o700)
#endif
try FileManager.default.removeItem(at: tmpDir)
}
func testMktempdirWithPrefix() throws {
let tmpDir = try Dir.mktmpdir(prefix: "fileotter")
XCTAssertTrue(tmpDir.path.contains("fileotter-"))
XCTAssertTrue(FileManager.default.fileExists(atPath: tmpDir.path))
try FileManager.default.removeItem(at: tmpDir)
}
func testMktempdirWithPrefixAndSuffix() throws {
let tmpDir = try Dir.mktmpdir(prefix: "test", suffix: "tmp")
XCTAssertTrue(tmpDir.lastPathComponent.hasPrefix("test-"))
XCTAssertTrue(tmpDir.lastPathComponent.hasSuffix("-tmp"))
XCTAssertTrue(FileManager.default.fileExists(atPath: tmpDir.path))
try FileManager.default.removeItem(at: tmpDir)
}
func testMktempdirWithBlock() throws {
var blockExecuted = false
var tmpDirInBlock: URL?
var fileCreated = false
let result = try Dir.mktmpdir(prefix: "block") { tmpDir in
blockExecuted = true
tmpDirInBlock = tmpDir
XCTAssertTrue(FileManager.default.fileExists(atPath: tmpDir.path))
let testFile = tmpDir.appendingPathComponent("test.txt")
try "Hello from mktmpdir block".write(to: testFile, atomically: true, encoding: .utf8)
fileCreated = FileManager.default.fileExists(atPath: testFile.path)
return "block result"
}
XCTAssertTrue(blockExecuted)
XCTAssertEqual(result, "block result")
XCTAssertTrue(fileCreated)
if let tmpDirInBlock = tmpDirInBlock {
XCTAssertFalse(FileManager.default.fileExists(atPath: tmpDirInBlock.path))
}
}
func testMktempdirWithBlockThrowingError() {
do {
try Dir.mktmpdir { tmpDir in
XCTAssertTrue(FileManager.default.fileExists(atPath: tmpDir.path))
throw NSError(domain: "TestError", code: 42)
}
XCTFail("Should have thrown an error")
} catch {
let nsError = error as NSError
XCTAssertEqual(nsError.domain, "TestError")
XCTAssertEqual(nsError.code, 42)
}
}
func testMktempdirEachCallCreatesUniqueDirectory() throws {
let tmpDir1 = try Dir.mktmpdir()
let tmpDir2 = try Dir.mktmpdir()
XCTAssertNotEqual(tmpDir1, tmpDir2)
XCTAssertTrue(FileManager.default.fileExists(atPath: tmpDir1.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: tmpDir2.path))
try FileManager.default.removeItem(at: tmpDir1)
try FileManager.default.removeItem(at: tmpDir2)
}
}