From f85da3e7b551834fef4614ef30a4d883aff1e210 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sun, 17 Aug 2025 14:17:50 -0700 Subject: [PATCH] Flesh out Dir a lot more --- .gitignore | 2 + FileOtter.xcodeproj/project.pbxproj | 4 + FileOtter/Dir.swift | 143 +++++--- FileOtter/Glob.swift | 121 +++++++ FileOtterTests/FileOtterTests.swift | 497 +++++++++++++++++++++++++++- 5 files changed, 695 insertions(+), 72 deletions(-) create mode 100644 .gitignore create mode 100644 FileOtter/Glob.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75d1780 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build +xcuserdata diff --git a/FileOtter.xcodeproj/project.pbxproj b/FileOtter.xcodeproj/project.pbxproj index 819ecad..b668c61 100644 --- a/FileOtter.xcodeproj/project.pbxproj +++ b/FileOtter.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; 7B5064B82BD9F236009CEFF9 /* FileOtter.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = FileOtter.docc; sourceTree = ""; }; @@ -82,6 +84,7 @@ 7B5064B82BD9F236009CEFF9 /* FileOtter.docc */, 7B5064D02BD9F322009CEFF9 /* File.swift */, 7B5064D22BD9F339009CEFF9 /* Dir.swift */, + 7B1B71E02E52784D008EDC0E /* Glob.swift */, ); path = FileOtter; sourceTree = ""; @@ -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 */, ); diff --git a/FileOtter/Dir.swift b/FileOtter/Dir.swift index abdaa72..c9c24f9 100644 --- a/FileOtter/Dir.swift +++ b/FileOtter/Dir.swift @@ -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 { + "" + } } // 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(_ url: URL, block: (URL) -> T) -> T { + static func chdir(_ 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( + 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 { + +public extension Dir { + func makeIterator() -> any IteratorProtocol { children.makeIterator() } - public subscript(position: Int) -> URL { + subscript(position: Int) -> URL { children[position] } } diff --git a/FileOtter/Glob.swift b/FileOtter/Glob.swift new file mode 100644 index 0000000..c47ea48 --- /dev/null +++ b/FileOtter/Glob.swift @@ -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() // 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() +} diff --git a/FileOtterTests/FileOtterTests.swift b/FileOtterTests/FileOtterTests.swift index 87000c6..bc363c4 100644 --- a/FileOtterTests/FileOtterTests.swift +++ b/FileOtterTests/FileOtterTests.swift @@ -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, "") + + let homeDir = try Dir(url: Dir.home) + let homeDebugDescription = String(reflecting: homeDir) + XCTAssertEqual(homeDebugDescription, "") + } + + 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) + } }