diff --git a/Package.resolved b/Package.resolved index 02e22f7..d045c66 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "db0ba74c125e968c67646390cbba012a5572a5c9c54171588ecbb73e370a448d", + "originHash" : "fd1e824e418c767633bb79b055a4e84d9c86165746bc881d5d27457ad34b0c20", "pins" : [ { "identity" : "noora", @@ -46,6 +46,15 @@ "version" : "1.1.3" } }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -64,6 +73,15 @@ "version" : "1.10.1" } }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio", + "state" : { + "revision" : "e932d3c4d8f77433c8f7093b5ebcbf91463948a0", + "version" : "2.95.0" + } + }, { "identity" : "swift-subprocess", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 0b3d974..4bf2e19 100644 --- a/Package.swift +++ b/Package.swift @@ -18,6 +18,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.0"), .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.3"), .package(url: "https://github.com/apple/swift-log", from: "1.10.1"), + .package(url: "https://github.com/apple/swift-nio", from: "2.95.0"), .package(url: "https://github.com/apple/swift-system", from: "1.6.4"), .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.3.0"), .package(url: "https://github.com/tuist/Noora", from: "0.55.1") @@ -29,6 +30,7 @@ let package = Package( dependencies: [ "CInotify", .product(name: "Logging", package: "swift-log"), + .product(name: "_NIOFileSystem", package: "swift-nio"), .product(name: "SystemPackage", package: "swift-system") ] ), diff --git a/Sources/Inotify/DirectoryResolver.swift b/Sources/Inotify/DirectoryResolver.swift new file mode 100644 index 0000000..4971d8f --- /dev/null +++ b/Sources/Inotify/DirectoryResolver.swift @@ -0,0 +1,44 @@ +import _NIOFileSystem + +public struct DirectoryResolver { + static let fileManager = FileSystem.shared + + public static func resolve(_ paths: String...) async throws -> [FilePath] { + try await Self.resolve(paths) + } + + static func resolve(_ paths: [String]) async throws -> [FilePath] { + var resolved: [FilePath] = [] + + for path in paths { + let itemPath = FilePath(path) + try await Self.ensure(itemPath, is: .directory) + + let allDirectoriesIncludingSelf = try await getAllSubdirectoriesAndSelf(at: itemPath) + resolved.append(contentsOf: allDirectoriesIncludingSelf) + } + + return resolved + } + + private static func ensure(_ path: FilePath, is fileType: FileType) async throws { + guard let fileInfo = try await fileManager.info(forFileAt: path) else { + throw DirectoryResolverError.pathNotFound(path) + } + + guard fileInfo.type == fileType else { + throw DirectoryResolverError.pathIsNoDirectory(path) + } + } + + private static func getAllSubdirectoriesAndSelf(at path: FilePath) async throws -> [FilePath] { + var result: [FilePath] = [] + let directoryHandle = try await fileManager.openDirectory(atPath: path) + for try await childContent in directoryHandle.listContents(recursive: true) { + guard childContent.type == .directory else { continue } + result.append(childContent.path) + } + try await directoryHandle.close() + return result + } +} diff --git a/Sources/Inotify/DirectoryResolverErrror.swift b/Sources/Inotify/DirectoryResolverErrror.swift new file mode 100644 index 0000000..4759ab6 --- /dev/null +++ b/Sources/Inotify/DirectoryResolverErrror.swift @@ -0,0 +1,16 @@ +import Foundation +import SystemPackage + +public enum DirectoryResolverError: LocalizedError, Equatable { + case pathNotFound(FilePath) + case pathIsNoDirectory(FilePath) + + var errorDescription: String { + switch self { + case .pathNotFound(let path): + return "Path not found: \(path)" + case .pathIsNoDirectory(let path): + return "Path is not a directory: \(path)" + } + } +} diff --git a/Sources/Inotify/Inotify.swift b/Sources/Inotify/Inotify.swift index 8614829..3b079cb 100644 --- a/Sources/Inotify/Inotify.swift +++ b/Sources/Inotify/Inotify.swift @@ -28,6 +28,17 @@ public actor Inotify { return wd } + @discardableResult + public func addRecursiveWatch(forDirectory path: String, mask: InotifyEventMask) async throws -> [CInt] { + let directoryPaths = try await DirectoryResolver.resolve(path) + var result: [CInt] = [] + for path in directoryPaths { + let wd = try self.addWatch(path: path.string, mask: mask) + result.append(wd) + } + return result + } + public func removeWatch(_ wd: CInt) throws { guard inotify_rm_watch(self.fd, wd) == 0 else { throw InotifyError.removeWatchFailed(watchDescriptor: wd, errno: cinotify_get_errno()) diff --git a/Tests/InotifyIntegrationTests/RecursiveEventTests.swift b/Tests/InotifyIntegrationTests/RecursiveEventTests.swift new file mode 100644 index 0000000..e330e29 --- /dev/null +++ b/Tests/InotifyIntegrationTests/RecursiveEventTests.swift @@ -0,0 +1,23 @@ +import Foundation +import Testing +@testable import Inotify + +@Suite("Recursive Event Detection") +struct RecursiveEventTests { + @Test func detectsFileCreationInSubfolder() async throws { + try await withTempDir { dir in + let subDirectory = "\(dir)/Subfolder" + let filepath = "\(subDirectory)/modify-target.txt" + try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true) + + let events = try await getEventsForTrigger( + in: dir, + mask: [.create], + recursive: true + ) { _ in try createFile(at: "\(filepath)", contents: "hello") } + + let createEvent = events.first { $0.mask.contains(.create) && $0.path.string == filepath } + #expect(createEvent != nil, "Expected CREATE for '\(filepath)', got: \(events)") + } + } +} diff --git a/Tests/InotifyIntegrationTests/Utilities/getEventsForTrigger.swift b/Tests/InotifyIntegrationTests/Utilities/getEventsForTrigger.swift index 42afa39..78a20e9 100644 --- a/Tests/InotifyIntegrationTests/Utilities/getEventsForTrigger.swift +++ b/Tests/InotifyIntegrationTests/Utilities/getEventsForTrigger.swift @@ -3,10 +3,15 @@ import Inotify func getEventsForTrigger( in dir: String, mask: InotifyEventMask, - trigger: @escaping (String) async throws -> Void + recursive: Bool = false, + trigger: @escaping (String) async throws -> Void, ) async throws -> [InotifyEvent] { let watcher = try Inotify() - try await watcher.addWatch(path: dir, mask: mask) + if recursive { + try await watcher.addRecursiveWatch(forDirectory: dir, mask: mask) + } else { + try await watcher.addWatch(path: dir, mask: mask) + } let eventTask = Task { var events: [InotifyEvent] = []