import Darwin import Foundation struct DiskCleanFileItem: Equatable, Sendable { let path: String let isDirectory: Bool let isSymlink: Bool let resolvedSymlinkTarget: String? } protocol DiskCleanFileSystemProviding: Sendable { func expandPathPattern(_ pattern: String) throws -> [DiskCleanFileItem] func itemInfo(at path: String) throws -> DiskCleanFileItem? func sizeOfItem(at path: String) throws -> Int64 func removeItem(at path: String) throws func deduplicatedParentChildPaths(_ paths: [String]) -> [String] } struct LocalDiskCleanFileSystem: DiskCleanFileSystemProviding, @unchecked Sendable { private let fileManager: FileManager private let homeDirectory: String init( fileManager: FileManager = .default, homeDirectory: String = NSHomeDirectory() ) { self.fileManager = fileManager self.homeDirectory = Self.normalizeSlashes(homeDirectory) } func expandPathPattern(_ pattern: String) throws -> [DiskCleanFileItem] { let expandedPattern = Self.normalizeSlashes(expandHome(in: pattern)) guard Self.containsGlob(expandedPattern) else { guard let item = try itemInfo(at: expandedPattern) else { return [] } return [item] } let matchedPaths = try Self.globPaths(matching: expandedPattern) return try deduplicatedParentChildPaths(matchedPaths) .compactMap { try itemInfo(at: $0) } } func itemInfo(at path: String) throws -> DiskCleanFileItem? { let expandedPath = Self.normalizeSlashes(expandHome(in: path)) guard fileManager.fileExists(atPath: expandedPath) || isSymlink(at: expandedPath) else { return nil } let attributes = try fileManager.attributesOfItem(atPath: expandedPath) let fileType = attributes[.type] as? FileAttributeType let isSymlink = fileType == .typeSymbolicLink let isDirectory = fileType == .typeDirectory return DiskCleanFileItem( path: expandedPath, isDirectory: isDirectory, isSymlink: isSymlink, resolvedSymlinkTarget: isSymlink ? try? fileManager.destinationOfSymbolicLink(atPath: expandedPath) : nil ) } func sizeOfItem(at path: String) throws -> Int64 { let expandedPath = Self.normalizeSlashes(expandHome(in: path)) let attributes = try fileManager.attributesOfItem(atPath: expandedPath) let fileType = attributes[.type] as? FileAttributeType if fileType != .typeDirectory || fileType == .typeSymbolicLink { return Int64((attributes[.size] as? NSNumber)?.int64Value ?? 0) } var total: Int64 = 0 if let enumerator = fileManager.enumerator( at: URL(fileURLWithPath: expandedPath), includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey, .fileSizeKey, .totalFileAllocatedSizeKey], options: [.skipsPackageDescendants] ) { for case let url as URL in enumerator { let itemAttributes = try fileManager.attributesOfItem(atPath: url.path) guard (itemAttributes[.type] as? FileAttributeType) != .typeDirectory else { continue } total += Int64((itemAttributes[.size] as? NSNumber)?.int64Value ?? 0) } } return total } func removeItem(at path: String) throws { let expandedPath = Self.normalizeSlashes(expandHome(in: path)) guard fileManager.fileExists(atPath: expandedPath) || isSymlink(at: expandedPath) else { return } try fileManager.removeItem(atPath: expandedPath) } func deduplicatedParentChildPaths(_ paths: [String]) -> [String] { let uniqueSortedPaths = Array(Set(paths.map(Self.normalizeSlashes))).sorted() var kept: [String] = [] var keptPathSet: Set = [] for path in uniqueSortedPaths { if Self.hasKeptParent(for: path, in: keptPathSet) { continue } kept.append(path) keptPathSet.insert(path) } return kept } private func expandHome(in pattern: String) -> String { if pattern == "~" || pattern == "$HOME" || pattern == "${HOME}" { return homeDirectory } if pattern.hasPrefix("~/") { return homeDirectory + String(pattern.dropFirst()) } if pattern.hasPrefix("$HOME/") { return homeDirectory + String(pattern.dropFirst("$HOME".count)) } if pattern.hasPrefix("${HOME}/") { return homeDirectory + String(pattern.dropFirst("${HOME}".count)) } return pattern } private func isSymlink(at path: String) -> Bool { guard let attributes = try? fileManager.attributesOfItem(atPath: path) else { return false } return (attributes[.type] as? FileAttributeType) == .typeSymbolicLink } private static func containsGlob(_ pattern: String) -> Bool { pattern.contains { "*?[".contains($0) } } private static func globPaths(matching pattern: String) throws -> [String] { var globResult = glob_t() defer { globfree(&globResult) } let status = pattern.withCString { glob($0, 0, nil, &globResult) } if status == GLOB_NOMATCH { return [] } guard status == 0 else { throw DiskCleanFileSystemError.globFailed(pattern: pattern, status: status) } guard let pathVector = globResult.gl_pathv else { return [] } return (0..) -> Bool { var currentPath = path while let slashIndex = currentPath.lastIndex(of: "/") { currentPath = String(currentPath[.. String { var normalized = path while normalized.contains("//") { normalized = normalized.replacingOccurrences(of: "//", with: "/") } return normalized } }